1]); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Buchungsjournal', 'content' => $this->renderView('bookings/journal', [ 'rows' => $result['rows'], 'total' => $result['total'], 'totalAll' => $totalAll, 'pages' => $result['pages'], 'page' => $page, 'filter' => $filter, 'years' => $years, 'accounts' => $accounts, 'testMode' => Setting::isTestMode(), ]), 'topbarActions' => 'CSV Export', ]); } public function export(array $params): void { $filter = $_GET; unset($filter['page']); $result = Booking::journal($filter, 1, 99999); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="buchungsjournal-' . date('Ymd') . '.csv"'); $out = fopen('php://output', 'w'); fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel fputcsv($out, [ 'Buchungsnr.', 'Datum', 'Belegnr.', 'Typ', 'Kategorie-Kto.', 'Kategorie-Bez.', 'Zahlungskonto-Kto.', 'Zahlungskonto-Bez.', 'Netto (€)', 'USt (%)', 'USt (€)', 'Brutto (€)', 'Buchungstext', 'Storno' ], ';'); foreach ($result['rows'] as $row) { fputcsv($out, [ $this->csvSanitize((string)$row['buchungs_nr']), $row['buchungs_datum'], $this->csvSanitize((string)$row['beleg_nr']), $row['buchungstyp'], $row['kategorie_nr'], $this->csvSanitize((string)$row['kategorie_name']), $row['zahlungs_nr'], $this->csvSanitize((string)$row['zahlungs_name']), number_format((float)$row['betrag_netto'], 2, ',', ''), number_format((float)$row['ust_satz'], 2, ',', ''), number_format((float)$row['betrag_ust'], 2, ',', ''), number_format((float)$row['betrag_brutto'], 2, ',', ''), $this->csvSanitize((string)$row['buchungstext']), $row['is_reversal'] ? 'Ja' : 'Nein', ], ';'); } fclose($out); } public function store(array $params): void { $belegId = (int)$params['beleg_id']; $data = $_POST; $errors = []; // Direkte Kontonummer-Eingabe: nur auflösen wenn nichts per Select gewählt foreach ([ 'kategorie_kontonummer' => 'kategorie_konto_id', 'zahlungs_kontonummer' => 'zahlungskonto_id', ] as $field => $idField) { if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang $nr = trim($data[$field] ?? ''); if ($nr !== '') { $acc = \Steuerkram\Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]); if (!$acc) { $errors[$idField] = "Kontonummer '$nr' nicht gefunden."; } else { $data[$idField] = $acc['id']; } } } // Validate if (empty($data['buchungstyp'])) $errors['buchungstyp'] = 'Pflichtfeld'; if (empty($data['kategorie_konto_id']) && !isset($errors['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld'; if (empty($data['zahlungskonto_id']) && !isset($errors['zahlungskonto_id'])) $errors['zahlungskonto_id'] = 'Pflichtfeld'; if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld'; if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '') $errors['betrag_netto'] = 'Pflichtfeld'; if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld'; if (empty($errors) && $data['kategorie_konto_id'] === $data['zahlungskonto_id']) { $errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.'; } if ($errors) { $this->renderBelegDetail($belegId, $errors, $data); return; } try { $data['beleg_id'] = $belegId; // Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung) \Steuerkram\Database::execute( 'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?', [ !empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null, !empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null, $belegId, ] ); // Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen $konto = \Steuerkram\Database::fetchOne( 'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?', [(int)$data['kategorie_konto_id']] ); $kontonummer = $konto['kontonummer'] ?? ''; if (($konto['kontenart'] ?? '') === 'Anlagevermögen') { $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; if ($assetId === null) { // Neues Anlagegut anlegen $assetId = \Steuerkram\Models\Asset::create([ 'bezeichnung' => $data['buchungstext'], 'account_id' => (int)$data['kategorie_konto_id'], 'anschaffungsdatum' => $data['buchungs_datum'], 'anschaffungskosten' => (float)$data['betrag_netto'], ]); } $data['asset_id'] = $assetId; } // Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657 if (in_array($kontonummer, ['4650', '4651', '4652'], true)) { $this->bucheBewirtungssplit($data, $kontonummer); } else { Booking::create($data); $eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0; \Steuerkram\Database::execute( 'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?', [$eigenanteil, $belegId] ); if ($eigenanteil === 1) { Booking::upsertEigenanteil30( $belegId, (float)$data['betrag_netto'], (float)($data['ust_satz'] ?? 0), $data['buchungs_datum'] ); } } View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage()); $this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data); } catch (\Exception $e) { $this->renderBelegDetail($belegId, ['_exception' => $e->getMessage()], $data); } } private function renderBelegDetail(int $belegId, array $errors, array $formData = []): void { $beleg = \Steuerkram\Models\Beleg::find($belegId); $bookings = \Steuerkram\Models\Booking::forBeleg($belegId); $accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]); $integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId); $assets = \Steuerkram\Models\Asset::all(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg ' . ($beleg['beleg_nr'] ?? ''), 'content' => $this->renderView('belege/detail', [ 'beleg' => $beleg, 'bookings' => $bookings, 'integrity' => $integrity, 'accounts' => $accounts, 'assets' => $assets, 'formErrors' => $errors, 'formData' => $formData, ]), ]); } private function bucheBewirtungssplit(array $data, string $kontonummer): void { // Abzugskonto ermitteln $abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' }; $abzugskonto = \Steuerkram\Database::fetchOne( 'SELECT id FROM accounts WHERE kontonummer = ?', [$abzugskontonummer] ); if (!$abzugskonto) { throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden."); } $nettoGesamt = round((float)$data['betrag_netto'], 2); $ustSatz = (float)($data['ust_satz'] ?? 0); $netto70 = round($nettoGesamt * 0.7, 2); $netto30 = round($nettoGesamt - $netto70, 2); // Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig) Booking::create(array_merge($data, [ 'betrag_netto' => $netto70, ])); // Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG) Booking::create(array_merge($data, [ 'kategorie_konto_id' => $abzugskonto['id'], 'betrag_netto' => $netto30, ])); } public function destroy(array $params): void { if (!Setting::isTestMode()) { View::redirect('/journal', 'Löschen ist im Produktivmodus nicht erlaubt (GoBD).', 'error'); return; } $id = (int)$params['id']; $booking = Booking::find($id); if (!$booking) { View::redirect('/journal', 'Buchung nicht gefunden.', 'error'); return; } \Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]); $redirect = !empty($_POST['beleg_id']) ? '/belege/' . (int)$_POST['beleg_id'] : '/journal'; View::redirect($redirect, 'Buchung gelöscht (Testmodus).'); } public function reverse(array $params): void { $id = (int)$params['id']; $reason = trim($_POST['reason'] ?? 'Manuelle Stornierung'); try { Booking::reverse($id, $reason); $booking = Booking::find($id); View::redirect("/belege/{$booking['beleg_id']}", 'Buchung wurde storniert.'); } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in BookingController::reverse(): ' . $e->getMessage()); View::redirect('/journal', 'Datenbankfehler beim Stornieren der Buchung.', 'error'); } catch (\Exception $e) { View::redirect('/journal', $e->getMessage(), 'error'); } } private function csvSanitize(string $value): string { if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { return "'" . $value; } return $value; } private function renderView(string $template, array $data): string { extract($data, EXTR_SKIP); ob_start(); include dirname(__DIR__) . '/Views/' . $template . '.php'; return ob_get_clean(); } }