'pdf',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/tiff' => 'tif',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
public function index(array $params): void
{
$filter = $_GET;
if (!isset($filter['year'])) $filter['year'] = (string)date('Y');
if (!isset($filter['month'])) $filter['month'] = (string)date('n');
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Beleg::all($filter, $page);
$totalAll = Beleg::all([], 1, 1)['total'];
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Belege',
'content' => $this->renderView('belege/list', [
'rows' => $result['rows'],
'total' => $result['total'],
'totalAll' => $totalAll,
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
'suppliers' => Supplier::all(),
]),
'topbarActions' => 'CSV Export '
. ''
. ''
. '',
]);
}
public function export(array $params): void
{
$filter = $_GET;
unset($filter['page']);
$result = Beleg::all($filter, 1, 99999);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="belegliste-' . date('Ymd') . '.csv"');
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel
fputcsv($out, [
'Belegnr.', 'Belegdatum', 'Belegart', 'Lieferant', 'Rechnungsnr.',
'Dateiname', 'Geschäftsjahr', 'Notizen',
'Bewirtungsgrund', 'Teilnehmerliste',
'Brutto (€)', 'Status',
], ';');
foreach ($result['rows'] as $row) {
if ($row['locked']) {
$status = 'Gesperrt';
} elseif ($row['booked']) {
$status = 'Gebucht';
} else {
$status = 'Offen';
}
fputcsv($out, [
$this->csvSanitize((string)($row['beleg_nr'] ?? '')),
$row['beleg_datum'] ?? '',
$row['belegtyp'] ?? '',
$this->csvSanitize((string)($row['supplier_name'] ?? '')),
$this->csvSanitize((string)($row['rechnungs_nr'] ?? '')),
$this->csvSanitize((string)($row['filename'] ?? '')),
(string)($row['fiscal_year_nr'] ?? ''),
$this->csvSanitize((string)($row['notes'] ?? '')),
$this->csvSanitize((string)($row['bewirtungsgrund'] ?? '')),
$this->csvSanitize((string)($row['teilnehmerliste'] ?? '')),
$row['betrag_brutto'] !== null
? number_format((float)$row['betrag_brutto'], 2, ',', '')
: '',
$status,
], ';');
}
fclose($out);
}
public function uploadForm(array $params): void
{
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg hochladen',
'content' => $this->renderView('belege/upload', ['errors' => []]),
]);
}
public function upload(array $params): void
{
$errors = [];
if (empty($_FILES['beleg_file']) || $_FILES['beleg_file']['error'] !== UPLOAD_ERR_OK) {
$errors['file'] = 'Keine gültige Datei übermittelt.';
} else {
$file = $_FILES['beleg_file'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$mimeKey = strtolower((string)$mime);
if (!isset(self::ALLOWED_MIME[$mimeKey])) {
$errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.';
}
}
if ($errors) {
$this->renderUploadErrors($errors);
return;
}
$file = $_FILES['beleg_file'];
$finfo2 = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_file($finfo2, $file['tmp_name']);
finfo_close($finfo2);
$ext = self::ALLOWED_MIME[strtolower($mime)];
$belegNr = Beleg::generateBelegNr();
$year = !empty($_POST['beleg_datum'])
? date('Y', strtotime($_POST['beleg_datum']))
: date('Y');
// Store under uploads/{year}/{belegNr}/original.{ext}
$relDir = "{$year}/{$belegNr}";
$absDir = Config::get('upload_dir') . "/{$relDir}";
if (!is_dir($absDir)) @mkdir($absDir, 0755, true);
// Ein bereits vorhandenes, aber nicht beschreibbares Zielverzeichnis (etwa
// ein Rückstand aus einem Testlauf mit anderem Nutzer) ließ move_uploaded_file()
// sonst mit einer Ausnahme und HTTP 500 auflaufen.
if (!is_dir($absDir) || !is_writable($absDir)) {
Beleg::releaseBelegNr($belegNr);
$this->renderUploadErrors([
'file' => "Das Zielverzeichnis {$relDir} ist nicht beschreibbar. "
. 'Bitte die Schreibrechte im Belegverzeichnis prüfen.',
]);
return;
}
$filename = pathinfo($file['name'], PATHINFO_BASENAME);
$filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME);
$storagePath = "{$relDir}/original.{$ext}";
$absPath = Config::get('upload_dir') . "/{$storagePath}";
if (!move_uploaded_file($file['tmp_name'], $absPath)) {
Beleg::releaseBelegNr($belegNr);
$this->renderUploadErrors([
'file' => 'Die hochgeladene Datei konnte nicht gespeichert werden. '
. 'Bitte die Schreibrechte im Belegverzeichnis prüfen.',
]);
return;
}
// Weißen Rand bei JPEG/PNG automatisch wegschneiden
if (in_array($mime, ['image/jpeg', 'image/png'], true)) {
(new ImageCropService())->cropWhitespace($absPath);
}
// Hash nach potenziellem Crop berechnen (reflektiert den tatsächlich gespeicherten Inhalt)
$hash = hash_file('sha256', $absPath);
$id = Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $filename,
'storage_path' => $storagePath,
'mime_type' => $mime,
'file_hash' => $hash,
'beleg_datum' => $_POST['beleg_datum'] ?? null,
'rechnungs_nr' => $filenameWithoutExt,
'betrag_brutto' => null,
'belegtyp' => $_POST['belegtyp'] ?? 'Eingangsrechnung',
'notes' => $_POST['notes'] ?? null,
]);
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json');
echo json_encode(['id' => $id, 'beleg_nr' => $belegNr]);
return;
}
View::redirect("/belege/{$id}", "Beleg {$belegNr} erfolgreich hochgeladen.");
}
/**
* Zeigt das Upload-Formular erneut mit Fehlermeldungen an – als JSON, wenn
* der Upload per AJAX kam.
*
* @param array $errors
*/
private function renderUploadErrors(array $errors): void
{
$dateiname = isset($_FILES['beleg_file']['name']) && $_FILES['beleg_file']['name'] !== ''
? pathinfo((string)$_FILES['beleg_file']['name'], PATHINFO_BASENAME)
: 'ohne Dateiname';
EventLog::tryAdd(
'beleg-upload',
"Upload fehlgeschlagen ({$dateiname}): " . implode(' ', $errors),
EventLog::LEVEL_ERROR
);
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode(['error' => implode(' ', $errors)]);
return;
}
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg hochladen',
'content' => $this->renderView('belege/upload', [
'errors' => $errors,
'old' => $_POST,
]),
]);
}
public function show(array $params): void
{
$beleg = Beleg::find((int)$params['id']);
if (!$beleg) { http_response_code(404); return; }
$bookings = Booking::forBeleg((int)$params['id']);
$integrity = Beleg::verifyIntegrity((int)$params['id']);
$accounts = Account::all(['aktiv' => 1]);
$suppliers = Supplier::all();
$assets = Asset::all();
$svc = new KontierungsvorschlagService();
$kiAktiv = $svc->isEnabled();
$kiVorschlag = $beleg['ki_vorschlag'] !== null
? json_decode($beleg['ki_vorschlag'], true)
: null;
$kiSektion = $kiAktiv || $kiVorschlag !== null;
// Bank-Kontext: Vorausfüllen aus Bankbuchung
$bankTx = null;
$bankTxSupplier = '';
$bankTxBrutto = 0.0;
$bankFormData = [];
if (!empty($_GET['bank_tx'])) {
$bankTx = BankTransaction::find((int)$_GET['bank_tx']);
}
if ($bankTx) {
// Auftraggeber extrahieren: "Auftraggeber: X Buchungstext" → X
if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) {
$bankTxSupplier = trim($m[1]);
}
// alloc_betrag aus URL hat Vorrang (Teilbetrag bei Aufteilung), sonst voller Betrag
$allocBetrag = isset($_GET['alloc_betrag']) ? round((float)$_GET['alloc_betrag'], 2) : 0.0;
$bankTxBrutto = $allocBetrag > 0 ? $allocBetrag : abs((float)$bankTx['wert']);
$netto = round($bankTxBrutto / 1.19, 2);
$bankFormData = [
'buchungs_datum' => $bankTx['wertstellung'],
'betrag_netto' => number_format($netto, 2, '.', ''),
'alloc_betrag' => number_format($bankTxBrutto, 2, '.', ''),
'zahlungs_kontonummer'=> '1200',
];
// Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist
if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') {
$supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier);
\Steuerkram\Database::execute(
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
[$supplierId, (int)$params['id']]
);
$beleg['supplier_name'] = $bankTxSupplier;
}
}
$linkedBankTx = BankTransaction::findByBelegId((int)$beleg['id']);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
'content' => $this->renderView('belege/detail', [
'beleg' => $beleg,
'suppliers' => $suppliers,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
'assets' => $assets,
'kiAktiv' => $kiAktiv,
'kiSektion' => $kiSektion,
'kiVorschlag' => $kiVorschlag,
'bankTx' => $bankTx,
'bankTxSupplier' => $bankTxSupplier,
'bankTxBrutto' => $bankTxBrutto,
'formData' => $bankFormData,
'linkedBankTx' => $linkedBankTx,
'vorlagen' => Buchungsvorlage::findAll(),
]),
]);
}
public function serveFile(array $params): void
{
$beleg = Beleg::find((int)$params['id']);
if (!$beleg) { http_response_code(404); return; }
// Beleg ohne physische Datei → PDF der verknüpften Rechnung inline ausliefern
if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) {
$invoice = \Steuerkram\Models\Invoice::find((int)$beleg['invoice_id']);
if (!$invoice) { http_response_code(404); return; }
if (!empty($invoice['storno_of_id'])) {
$original = \Steuerkram\Models\Invoice::find((int)$invoice['storno_of_id']);
if ($original) {
$invoice['original_rechnungs_nr'] = $original['rechnungs_nr'];
$invoice['original_rechnungs_datum'] = $original['rechnungs_datum'];
}
}
$lines = \Steuerkram\Models\Invoice::lines((int)$invoice['id']);
$settings = \Steuerkram\Models\Setting::get();
$svc = new \Steuerkram\Services\PdfInvoiceService();
$pdf = $svc->renderToString($invoice, $lines, $settings);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
}
$basePath = (string)realpath(Config::get('upload_dir'));
$absPath = Beleg::absolutePath($beleg);
$realPath = realpath($absPath);
if ($realPath === false || !str_starts_with($realPath, $basePath)) {
http_response_code(403);
return;
}
if (!file_exists($realPath)) { http_response_code(404); return; }
// XML kann eingebettete XSLT-Stylesheets enthalten, die bei Inline-Anzeige
// im Browser (v.a. Firefox) als Stored-XSS ausgeführt würden -> Download erzwingen.
$isXml = in_array($beleg['mime_type'], ['application/xml', 'text/xml'], true);
$disposition = $isXml ? 'attachment' : 'inline';
header('Content-Type: ' . $beleg['mime_type']);
header('Content-Disposition: ' . $disposition . '; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . filesize($realPath));
readfile($realPath);
}
public function updateMeta(array $params): void
{
$isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
if ($isAjax) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Nicht gefunden.']); return; }
http_response_code(404); return;
}
if ($beleg['locked']) {
if ($isAjax) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); return; }
View::redirect("/belege/{$id}", 'GoBD: Gesperrter Beleg kann nicht geändert werden.', 'error');
return;
}
// Lieferant: Name → ID (ggf. neu anlegen)
$supplierId = null;
$supplierName = trim($_POST['supplier_name'] ?? '');
if ($supplierName !== '') {
$supplierId = Supplier::findOrCreate($supplierName);
}
Beleg::updateMeta($id, [
'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''),
'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'],
'beleg_datum' => $_POST['beleg_datum'] ?: null,
'supplier_id' => $supplierId,
]);
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
return;
}
View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
}
public function updateSupplier(array $params): void
{
header('Content-Type: application/json');
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
http_response_code(404);
echo json_encode(['error' => 'Nicht gefunden.']);
return;
}
if ($beleg['locked']) {
http_response_code(403);
echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']);
return;
}
$supplierName = trim($_POST['supplier_name'] ?? '');
$supplierId = $supplierName !== '' ? Supplier::findOrCreate($supplierName) : null;
Beleg::updateSupplier($id, $supplierId);
echo json_encode(['ok' => true, 'supplier_name' => $supplierName !== '' ? $supplierName : null]);
}
public function generateVorschlag(array $params): void
{
header('Content-Type: application/json');
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
echo json_encode(['error' => 'Beleg nicht gefunden']);
return;
}
$svc = new KontierungsvorschlagService();
$vorschlag = $svc->generateForBeleg($beleg);
// [] = "verarbeitet, kein Ergebnis"; null würde endlose Auto-Retry-Schleife auslösen
Beleg::saveKiVorschlag($id, $vorschlag ?? []);
if ($vorschlag === null) {
$llm = new \Steuerkram\Services\LlmService();
echo json_encode($llm->isReachable()
? ['error' => 'Kein Vorschlag generiert']
: ['connection_error' => true]
);
return;
}
echo json_encode($vorschlag);
}
public function resetVorschlag(array $params): void
{
header('Content-Type: application/json');
Beleg::saveKiVorschlag((int)$params['id'], null);
echo json_encode(['ok' => true]);
}
public function destroy(array $params): void
{
$id = (int)$params['id'];
if (!Beleg::delete($id)) {
View::redirect('/belege', 'Beleg kann nicht gelöscht werden (gesperrt oder enthält Buchungen).', 'error');
return;
}
View::redirect('/belege', 'Beleg gelöscht.');
}
public function cropImage(array $params): void
{
header('Content-Type: application/json');
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
http_response_code(404);
echo json_encode(['error' => 'Beleg nicht gefunden.']);
return;
}
if ($beleg['locked']) {
http_response_code(403);
echo json_encode(['error' => 'GoBD: Gesperrter Beleg kann nicht verändert werden.']);
return;
}
if (!in_array($beleg['mime_type'], ['image/jpeg', 'image/png'], true)) {
http_response_code(422);
echo json_encode(['error' => 'Zuschneiden nur für JPEG und PNG möglich.']);
return;
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$x = (int)($body['x'] ?? 0);
$y = (int)($body['y'] ?? 0);
$w = (int)($body['w'] ?? 0);
$h = (int)($body['h'] ?? 0);
if ($w < 10 || $h < 10) {
http_response_code(422);
echo json_encode(['error' => 'Auswahlbereich zu klein (mindestens 10×10 Pixel).']);
return;
}
$absPath = Beleg::absolutePath($beleg);
if (!file_exists($absPath)) {
http_response_code(404);
echo json_encode(['error' => 'Datei nicht gefunden.']);
return;
}
$svc = new ImageCropService();
if (!$svc->cropToRect($absPath, $x, $y, $w, $h)) {
http_response_code(500);
echo json_encode(['error' => 'Zuschnitt fehlgeschlagen.']);
return;
}
Beleg::updateHash($id, hash_file('sha256', $absPath));
echo json_encode(['ok' => true]);
}
}