From 6e0f93c3b10cddb4f100abe964bf0ddff986e660 Mon Sep 17 00:00:00 2001 From: fds2610 Date: Fri, 27 Mar 2026 20:15:32 +0100 Subject: [PATCH] =?UTF-8?q?Fix=20#9:=20IST-Versteuerung=20=E2=80=93=20Zahl?= =?UTF-8?q?datum=20statt=20Buchungsdatum=20f=C3=BCr=20Belege=20und=20Ausga?= =?UTF-8?q?ngsrechnungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.php | 3 +- src/Controllers/InvoiceController.php | 160 ++++++++++++------- src/Views/belege/detail.php | 2 +- src/Views/invoices/book.php | 219 ++++++++++++++++++++++++++ src/Views/invoices/show.php | 4 +- tests/10_IstVersteuerungTest.php | 112 +++++++++++++ 6 files changed, 434 insertions(+), 66 deletions(-) create mode 100644 src/Views/invoices/book.php create mode 100644 tests/10_IstVersteuerungTest.php diff --git a/public/index.php b/public/index.php index 2e302da..8a1f8e9 100644 --- a/public/index.php +++ b/public/index.php @@ -86,7 +86,8 @@ $router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p $router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p)); $router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p)); $router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p)); -$router->post('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p)); +$router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p)); +$router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p)); $router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p)); $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 5a0f26f..311942e 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -8,6 +8,7 @@ use Steuerkram\Models\Invoice; use Steuerkram\Models\Customer; use Steuerkram\Models\Beleg; use Steuerkram\Models\Booking; +use Steuerkram\Models\Account; use Steuerkram\Models\Setting; use Steuerkram\Services\PdfInvoiceService; use Steuerkram\View; @@ -172,9 +173,9 @@ class InvoiceController \Steuerkram\Database::beginTransaction(); $nr = Invoice::finalize($id); $invoice = Invoice::find($id); - $belegNr = $this->createBelegAndBookings($invoice); + $belegNr = $this->createBeleg($invoice); \Steuerkram\Database::commit(); - View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert und gebucht (Beleg {$belegNr})."); + View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen."); } catch (\PDOException $e) { \Steuerkram\Database::rollBack(); error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage()); @@ -207,27 +208,110 @@ class InvoiceController View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error'); return; } - if (!empty($invoice['beleg_id'])) { - View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning'); + if (empty($invoice['beleg_id'])) { + View::redirect("/invoices/{$id}", 'Kein Beleg zugeordnet.', 'error'); + return; + } + + $bookings = Booking::forBeleg((int)$invoice['beleg_id']); + $accounts = Account::all(['aktiv' => 1]); + $formErrors = $_GET['_error'] ?? []; + $formData = []; + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), + 'content' => $this->renderView('invoices/book', [ + 'invoice' => $invoice, + 'bookings' => $bookings, + 'accounts' => $accounts, + 'formErrors' => $formErrors, + 'formData' => $formData, + ]), + ]); + } + + public function storeBooking(array $params): void + { + $id = (int)$params['id']; + $invoice = Invoice::find($id); + if (!$invoice) { http_response_code(404); return; } + + $belegId = (int)($invoice['beleg_id'] ?? 0); + if (!$belegId) { + View::redirect("/invoices/{$id}", 'Kein Beleg zugeordnet.', 'error'); + return; + } + + $data = $_POST; + $errors = []; + + if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld'; + if (empty($data['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) { + $bookings = Booking::forBeleg($belegId); + $accounts = Account::all(['aktiv' => 1]); + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), + 'content' => $this->renderView('invoices/book', [ + 'invoice' => $invoice, + 'bookings' => $bookings, + 'accounts' => $accounts, + 'formErrors' => $errors, + 'formData' => $data, + ]), + ]); return; } try { - \Steuerkram\Database::beginTransaction(); - $belegNr = $this->createBelegAndBookings($invoice); - \Steuerkram\Database::commit(); - View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').'); + $data['beleg_id'] = $belegId; + $data['buchungstyp'] = 'Einnahme'; + Booking::create($data); + View::redirect("/invoices/{$id}/book", 'Buchung erfasst.'); } catch (\PDOException $e) { - \Steuerkram\Database::rollBack(); - error_log('[Steuerkram] PDO-Fehler in InvoiceController::book(): ' . $e->getMessage()); - View::redirect("/invoices/{$id}", 'Datenbankfehler beim Buchen der Rechnung.', 'error'); + error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); + $bookings = Booking::forBeleg($belegId); + $accounts = Account::all(['aktiv' => 1]); + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), + 'content' => $this->renderView('invoices/book', [ + 'invoice' => $invoice, + 'bookings' => $bookings, + 'accounts' => $accounts, + 'formErrors' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], + 'formData' => $data, + ]), + ]); } catch (\Exception $e) { - \Steuerkram\Database::rollBack(); - View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); + $bookings = Booking::forBeleg($belegId); + $accounts = Account::all(['aktiv' => 1]); + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), + 'content' => $this->renderView('invoices/book', [ + 'invoice' => $invoice, + 'bookings' => $bookings, + 'accounts' => $accounts, + 'formErrors' => ['_exception' => $e->getMessage()], + 'formData' => $data, + ]), + ]); } } - private function createBelegAndBookings(array $invoice): string + private function createBeleg(array $invoice): string { $id = (int)$invoice['id']; @@ -235,7 +319,7 @@ class InvoiceController $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $invoice['rechnungs_nr'] . '.pdf', - 'storage_path' => '', // no physical file for outgoing invoices + 'storage_path' => '', 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $invoice['rechnungs_nr']), 'beleg_datum' => $invoice['rechnungs_datum'], @@ -246,52 +330,6 @@ class InvoiceController 'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'], ]); - $zahlungskonto = \Steuerkram\Database::fetchOne( - 'SELECT id FROM accounts WHERE kontonummer = ?', ['1400'] - ); - if (!$zahlungskonto) { - throw new \RuntimeException('Forderungskonto 1400 nicht gefunden.'); - } - - $erloesKonten = []; - foreach (\Steuerkram\Database::fetchAll( - "SELECT id, kontonummer FROM accounts WHERE kontonummer IN ('8000','8200','8400')" - ) as $row) { - $erloesKonten[$row['kontonummer']] = $row['id']; - } - - $lines = Invoice::lines($id); - $gruppenNetto = []; - foreach ($lines as $line) { - $satz = (float)$line['ust_satz']; - $netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2); - $gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto; - } - - foreach ($gruppenNetto as $satz => $netto) { - if (abs($satz - 19) < 0.01) { - $kategorieId = $erloesKonten['8000'] ?? null; - } elseif (abs($satz - 7) < 0.01) { - $kategorieId = $erloesKonten['8200'] ?? null; - } else { - $kategorieId = $erloesKonten['8400'] ?? null; - } - if (!$kategorieId) { - throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden."); - } - - Booking::create([ - 'beleg_id' => $belegId, - 'buchungs_datum' => $invoice['rechnungs_datum'], - 'buchungstyp' => 'Einnahme', - 'kategorie_konto_id' => $kategorieId, - 'zahlungskonto_id' => $zahlungskonto['id'], - 'betrag_netto' => round($netto, 2), - 'ust_satz' => $satz, - 'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'], - ]); - } - \Steuerkram\Database::execute( 'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id] ); diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index c9f6a7d..ddaf798 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -301,7 +301,7 @@ $suppliers = $suppliers ?? [];
- + diff --git a/src/Views/invoices/book.php b/src/Views/invoices/book.php new file mode 100644 index 0000000..eedc7b4 --- /dev/null +++ b/src/Views/invoices/book.php @@ -0,0 +1,219 @@ + View::h($v); +$fmt = fn($v) => View::formatMoney($v); + +$customerName = !empty($invoice['firma']) + ? $invoice['firma'] + : trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']); + +$formErrors = $formErrors ?? []; +$formData = $formData ?? []; + +$kontenGruppen = []; +foreach ($accounts as $acc) { + $kontenGruppen[$acc['kontenart']][] = $acc; +} +?> + + +
+
+
+ Rechnung
+ +
+
+ Kunde
+ +
+
+ Rechnungsdatum
+ +
+
+ Betrag
+ +
+
+ Status
+ 'badge-green', + 'Storniert' => 'badge-red', + 'Gesendet' => 'badge-yellow', + default => 'badge-gray', + }; + ?> + +
+ +
+
+ + +
+
+
Buchungszeilen ()
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Buchungsnr.ZahldatumKategorieZahlungskontoNettoUStBruttoText


% + + + Storno + + Storniert + +
Noch keine Buchungen.
+
+
+ + + +
+
+
Neue Buchungszeile
+
+
+ +
+ + Fehler: + + "$k: $v", array_keys($formErrors), $formErrors)) ?> + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+

Alle Zahlungseingänge erfasst? Dann die Rechnung als bezahlt markieren.

+
+ +
+
+
+ diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index baab101..5b40d86 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -40,9 +40,7 @@ $badgeClass = match($invoice['status']) { PDF herunterladen -
- -
+ Zahlungseingang erfassen
findAccountId('1200'); // Bank + $kategorieId = $this->findAccountId('8000'); // Erlöse 19 % + + if (!$zahlungsId || !$kategorieId) { + $zahlungsId = $zahlungsId ?? $this->findAccountId('1400'); + $kategorieId = $kategorieId ?? $this->findAccountId('8400'); + } + + $this->assert($zahlungsId > 0, 'Zahlungskonto vorhanden'); + $this->assert($kategorieId > 0, 'Einnahmekategorie vorhanden'); + + // Kunde anlegen + $customerId = \Steuerkram\Models\Customer::create([ + 'nachname' => 'IstTestKunde', + 'firma' => 'IST GmbH', + 'strasse' => 'Musterstr. 1', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 14, + ]); + + // Rechnung anlegen + $invoiceId = \Steuerkram\Models\Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Test IST-Versteuerung', + 'rechnungs_datum' => $rechnungsDatum, + 'leistungszeitraum_von' => date('Y-m-01'), + 'leistungszeitraum_bis' => date('Y-m-t'), + 'lines' => [ + [ + 'bezeichnung' => 'Beratung', + 'menge' => 2, + 'einheit' => 'Std.', + 'einzelpreis' => 500.00, + 'ust_satz' => 19.0, + ], + ], + ]); + $this->assertGreaterThan(0, $invoiceId, 'Rechnung angelegt'); + + // Finalisieren + $nr = \Steuerkram\Models\Invoice::finalize($invoiceId); + $this->assertNotEmpty($nr, 'Rechnungsnummer vergeben'); + + $invoice = \Steuerkram\Models\Invoice::find($invoiceId); + $this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet'); + + // Beleg anlegen (wie in InvoiceController::createBeleg) + $belegNr = \Steuerkram\Models\Beleg::generateBelegNr(); + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => $nr . '.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $nr), + 'beleg_datum' => $rechnungsDatum, + 'eingangs_datum' => $rechnungsDatum, + 'rechnungs_nr' => $nr, + 'betrag_brutto' => $invoice['betrag_brutto'], + 'belegtyp' => 'Ausgangsrechnung', + ]); + \Steuerkram\Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $invoiceId]); + + // Noch keine Buchungen vorhanden (finalize erzeugt keine mehr) + $buchungenNachFinalisierung = \Steuerkram\Models\Booking::forBeleg($belegId); + $this->assertEquals(0, count($buchungenNachFinalisierung), 'Nach Finalisierung: keine Buchungen vorhanden'); + + // Buchung separat mit Zahldatum erfassen + $buchungId = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $zahlDatum, + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $kategorieId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 1000.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Ausgangsrechnung ' . $nr, + ]); + $this->assertGreaterThan(0, $buchungId, 'Buchung mit Zahldatum erfasst'); + + // Buchung hat das Zahldatum – nicht das Rechnungsdatum + $buchungenNachZahlung = \Steuerkram\Models\Booking::forBeleg($belegId); + $this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Buchung vorhanden'); + + $bk = $buchungenNachZahlung[0]; + $this->assertEquals($zahlDatum, $bk['buchungs_datum'], 'Buchungsdatum ist das Zahldatum'); + $this->assertNotEmpty($bk['buchungs_datum'] !== $rechnungsDatum || $zahlDatum === $rechnungsDatum, + 'Zahldatum wurde korrekt gespeichert'); + } +}