From 38cc8f885e3cc3a04c2def98c61547bc500fdacf Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 09:11:46 +0200 Subject: [PATCH 01/15] =?UTF-8?q?Feature:=20Stornorechnung=20gem=C3=A4?= =?UTF-8?q?=C3=9F=20=C2=A714=20UStG=20implementiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 12 +++ docs/funktionen.md | 1 + migrations/042_invoice_storno_of_id.sql | 6 ++ src/Controllers/InvoiceController.php | 36 ++++++-- src/Models/Invoice.php | 57 ++++++++++++- src/Services/PdfInvoiceService.php | 34 +++++--- src/Views/invoices/list.php | 8 ++ src/Views/invoices/show.php | 20 ++++- src/version.txt | 2 +- tests/27_StornorechnungTest.php | 106 ++++++++++++++++++++++++ 10 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 migrations/042_invoice_storno_of_id.sql create mode 100644 tests/27_StornorechnungTest.php diff --git a/Changes.md b/Changes.md index 701a441..14373ef 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-05-05 – Feature: Stornorechnung (§ 14 UStG) + +### Hinzugefügt +- `migrations/042_invoice_storno_of_id.sql`: Neue Spalte `storno_of_id` in `invoices` (FK auf Original) +- `Invoice::createCancellation()`: Legt Stornorechnung mit negierten Beträgen an, setzt Original auf „Storniert", erzeugt GoBD-konforme Stornobuchungen +- Storno-Button in Rechnungsübersicht (`list.php`) und Detailansicht (`show.php`) +- Querverweise Original ↔ Stornorechnung in der Detailansicht +- PDF-Anpassung: Titel „STORNORECHNUNG", Referenzzeile, kein Zahlungshinweis, angepasster Dateiname +- Test `27_StornorechnungTest.php` + +--- + ## 2026-04-28 – Feature: E-Rechnung / ZUGFeRD EN16931 ### Hinzugefügt diff --git a/docs/funktionen.md b/docs/funktionen.md index ef6fce8..9a02da2 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -52,6 +52,7 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen - Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung - **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931; Pflichtangaben laut § 14 UStG vollständig abgedeckt; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x - E-Mail-Versand via SMTP direkt aus der Anwendung +- **Stornorechnung (§ 14 UStG)**: Finalisierte, gesendete oder bezahlte Rechnungen können storniert werden; dabei entsteht automatisch eine nummerierte Stornorechnung mit negierten Beträgen (`storno_of_id`), GoBD-konforme Stornobuchungen heben die Originalbuchungen auf; Querverweise in der Detailansicht, angepasstes PDF-Layout (Titel „STORNORECHNUNG", Referenzzeile) --- diff --git a/migrations/042_invoice_storno_of_id.sql b/migrations/042_invoice_storno_of_id.sql new file mode 100644 index 0000000..afe010e --- /dev/null +++ b/migrations/042_invoice_storno_of_id.sql @@ -0,0 +1,6 @@ +ALTER TABLE invoices + ADD COLUMN storno_of_id INT UNSIGNED NULL DEFAULT NULL + COMMENT 'FK zur stornierten Originalrechnung' + AFTER beleg_id, + ADD CONSTRAINT fk_invoices_storno_of + FOREIGN KEY (storno_of_id) REFERENCES invoices(id) ON DELETE SET NULL; diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index fff9f91..87baf15 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -104,13 +104,30 @@ class InvoiceController $lines = Invoice::lines((int)$params['id']); $settings = Setting::get(); + // Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung? + $stornorechnung = \Steuerkram\Database::fetchOne( + 'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1', + [(int)$params['id']] + ); + + // Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden + $originalrechnung = null; + if (!empty($invoice['storno_of_id'])) { + $originalrechnung = \Steuerkram\Database::fetchOne( + 'SELECT id, rechnungs_nr FROM invoices WHERE id = ?', + [(int)$invoice['storno_of_id']] + ); + } + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'), 'content' => $this->renderView('invoices/show', [ - 'invoice' => $invoice, - 'lines' => $lines, - 'settings' => $settings, + 'invoice' => $invoice, + 'lines' => $lines, + 'settings' => $settings, + 'stornorechnung' => $stornorechnung ?: null, + 'originalrechnung' => $originalrechnung, ]), ]); } @@ -247,6 +264,15 @@ class InvoiceController $invoice = Invoice::find((int)$params['id']); if (!$invoice) { http_response_code(404); return; } + // Bei Stornorechnung Originalrechnung laden für Referenzzeile + if (!empty($invoice['storno_of_id'])) { + $original = Invoice::find((int)$invoice['storno_of_id']); + if ($original) { + $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; + $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; + } + } + $lines = Invoice::lines((int)$params['id']); $settings = Setting::get(); @@ -411,8 +437,8 @@ class InvoiceController public function cancel(array $params): void { - Invoice::cancel((int)$params['id']); - View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.'); + $stornoId = Invoice::createCancellation((int)$params['id']); + View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.'); } public function addLine(array $params): void diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 821fc6a..5e98207 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -53,7 +53,7 @@ class Invoice public static function find(int $id): array|false { return Database::fetchOne( - 'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer, + 'SELECT i.*, i.storno_of_id, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer, c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days FROM invoices i JOIN customers c ON i.customer_id = c.id @@ -181,6 +181,61 @@ class Invoice Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]); } + public static function createCancellation(int $originalId): int + { + $original = self::find($originalId); + if (!$original) { + throw new \RuntimeException('Originalrechnung nicht gefunden.'); + } + if (!in_array($original['status'], ['Finalisiert', 'Gesendet', 'Bezahlt'])) { + throw new \RuntimeException('Nur finalisierte, gesendete oder bezahlte Rechnungen können storniert werden.'); + } + if (!empty($original['storno_of_id'])) { + throw new \RuntimeException('Eine Stornorechnung kann nicht erneut storniert werden.'); + } + + $originalLines = self::lines($originalId); + $today = date('Y-m-d'); + $fy = FiscalYear::findOrCreateByYear((int)date('Y')); + $nr = self::generateRechnungsNr(); + + Database::execute( + 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum, + status, rechnungs_nr, fiscal_year_id, storno_of_id) + VALUES (?, ?, ?, ?, ?, \'Finalisiert\', ?, ?, ?)', + [ + (int)$original['customer_id'], + 'Stornorechnung zu Rechnung ' . $original['rechnungs_nr'], + $original['leistungszeitraum'], + $today, + $today, + $nr, + $fy['id'], + $originalId, + ] + ); + + $stornoId = (int)Database::lastInsertId(); + + $lines = []; + foreach ($originalLines as $line) { + $lines[] = [ + 'type' => $line['type'] ?? 'position', + 'bezeichnung' => $line['bezeichnung'], + 'menge' => $line['menge'], + 'einheit' => $line['einheit'], + 'einzelpreis' => -round((float)$line['einzelpreis'], 2), + 'ust_satz' => $line['ust_satz'], + ]; + } + self::saveLines($stornoId, $lines); + self::recalcTotals($stornoId); + + self::cancel($originalId); + + return $stornoId; + } + private static function saveLines(int $invoiceId, array $lines): void { $posCounter = 0; diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index df4dc73..d6b9bcf 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -16,7 +16,8 @@ class PdfInvoiceService public function render(array $invoice, array $lines, array $settings): void { $pdfContent = $this->generate($invoice, $lines, $settings); - $filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; + $prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-'; + $filename = $prefix . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . $filename . '"'); @@ -102,10 +103,23 @@ class PdfInvoiceService $lz = $inv['leistungszeitraum'] ?? ''; - // Fälligkeitsdatum - $fälligText = !empty($inv['faellig_datum']) - ? ' Fällig am: ' . $fmtDate($inv['faellig_datum']) . '' - : ''; + $isStorno = !empty($inv['storno_of_id']); + $docTitle = $isStorno ? 'STORNORECHNUNG' : 'Rechnung'; + + // Referenzzeile für Stornorechnung + $stornoRefHtml = ''; + if ($isStorno && !empty($inv['original_rechnungs_nr'])) { + $stornoRefHtml = '
' + . 'Stornierung der Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '' + . ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') + . '
'; + } + + // Fälligkeitsdatum (nicht bei Stornorechnung) + $fälligText = ''; + if (!$isStorno && !empty($inv['faellig_datum'])) { + $fälligText = ' Fällig am: ' . $fmtDate($inv['faellig_datum']) . ''; + } // Positionszeilen $posRows = ''; @@ -153,10 +167,12 @@ class PdfInvoiceService - Rechnung + {$docTitle} + {$stornoRefHtml} +
@@ -208,10 +224,8 @@ class PdfInvoiceService
- -

- Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText} -

+ + {$isStorno ? '' : '

Bitte überweisen Sie den Rechnungsbetrag innerhalb ' . $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '

'} HTML; diff --git a/src/Views/invoices/list.php b/src/Views/invoices/list.php index 3d79c5e..4dd373b 100644 --- a/src/Views/invoices/list.php +++ b/src/Views/invoices/list.php @@ -64,6 +64,14 @@ PDF + +
+ +
+
diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 106563a..4df5dc3 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -18,6 +18,18 @@ $badgeClass = match($invoice['status']) { }; ?> + + +
+ Stornorechnung zu: +
+ + +
+ Wurde storniert durch: +
+ +
@@ -41,9 +53,9 @@ $badgeClass = match($invoice['status']) { Zahlungseingang erfassen - + + data-confirm="Rechnung stornieren und Stornorechnung erstellen?"> @@ -90,7 +102,7 @@ $badgeClass = match($invoice['status']) {
-
Rechnung
+
@@ -161,10 +173,12 @@ $badgeClass = match($invoice['status']) { +
Bitte überweisen Sie den Rechnungsbetrag innerhalb Tagen auf das unten genannte Konto. Fällig am:
+ diff --git a/src/version.txt b/src/version.txt index 91fa3d4..af7d1fa 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260503 +1-260505 diff --git a/tests/27_StornorechnungTest.php b/tests/27_StornorechnungTest.php new file mode 100644 index 0000000..8bba073 --- /dev/null +++ b/tests/27_StornorechnungTest.php @@ -0,0 +1,106 @@ + 'StornoTester', + 'vorname' => 'Test', + 'firma' => 'Storno GmbH', + 'strasse' => 'Testweg 1', + 'plz' => '10115', + 'ort' => 'Berlin', + 'land' => 'DE', + 'payment_days' => 14, + ]); + + // Originalrechnung erstellen und finalisieren + $originalId = \Steuerkram\Models\Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung für Storno', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [ + [ + 'bezeichnung' => 'Beratung', + 'menge' => 2, + 'einheit' => 'Std.', + 'einzelpreis' => 100.00, + 'ust_satz' => 19.0, + ], + [ + 'bezeichnung' => 'Reisekosten', + 'menge' => 1, + 'einheit' => 'Pauschal', + 'einzelpreis' => 50.00, + 'ust_satz' => 19.0, + ], + ], + ]); + + \Steuerkram\Models\Invoice::finalize($originalId); + $original = \Steuerkram\Models\Invoice::find($originalId); + + $this->assertNotEmpty($original['rechnungs_nr'], 'Original hat Rechnungsnummer'); + $this->assertEquals('Finalisiert', $original['status'], 'Original ist finalisiert'); + $this->assertEquals(250.00, round((float)$original['betrag_netto'], 2), 'Original Nettobetrag korrekt'); + + // Stornorechnung erstellen + $stornoId = \Steuerkram\Models\Invoice::createCancellation($originalId); + $this->assertGreaterThan(0, $stornoId, 'createCancellation gibt gültige ID zurück'); + + // Stornorechnung prüfen + $storno = \Steuerkram\Models\Invoice::find($stornoId); + $this->assertNotEmpty($storno, 'Stornorechnung gefunden'); + $this->assertEquals($originalId, (int)$storno['storno_of_id'], 'storno_of_id zeigt auf Original'); + $this->assertEquals('Finalisiert', $storno['status'], 'Stornorechnung ist sofort finalisiert'); + $this->assertNotEmpty($storno['rechnungs_nr'], 'Stornorechnung hat Rechnungsnummer'); + $this->assert($original['rechnungs_nr'] !== $storno['rechnungs_nr'], 'Rechnungsnummern sind verschieden'); + + // Beträge sind negiert + $this->assertEquals(-250.00, round((float)$storno['betrag_netto'], 2), 'Nettobetrag negiert'); + $this->assertEquals(-47.50, round((float)$storno['betrag_ust'], 2), 'USt-Betrag negiert'); + $this->assertEquals(-297.50, round((float)$storno['betrag_brutto'], 2), 'Bruttobetrag negiert'); + + // Originalrechnung ist storniert + $originalNach = \Steuerkram\Models\Invoice::find($originalId); + $this->assertEquals('Storniert', $originalNach['status'], 'Originalrechnung hat Status Storniert'); + + // Positionen der Stornorechnung sind negiert + $stornoLines = \Steuerkram\Models\Invoice::lines($stornoId); + $this->assertEquals(2, count($stornoLines), 'Stornorechnung hat 2 Positionen'); + foreach ($stornoLines as $line) { + if (($line['type'] ?? 'position') === 'position') { + $this->assert((float)$line['menge'] > 0, 'Menge bleibt positiv'); + $this->assert((float)$line['einzelpreis'] < 0, 'Einzelpreis ist negativ'); + } + } + + // Stornobuchungen müssen angelegt worden sein (Original hatte beleg_id nach finalize) + $originalMitBeleg = \Steuerkram\Models\Invoice::find($originalId); + if (!empty($originalMitBeleg['beleg_id'])) { + $bookings = \Steuerkram\Database::fetchAll( + 'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 1', + [(int)$originalMitBeleg['beleg_id']] + ); + $this->assertGreaterThan(0, count($bookings), 'Stornobuchungen wurden angelegt'); + } + + // Erneutes Stornieren der Stornorechnung muss fehlschlagen + $exception = false; + try { + \Steuerkram\Models\Invoice::createCancellation($stornoId); + } catch (\RuntimeException $e) { + $exception = true; + } + $this->assert($exception, 'Stornorechnung kann nicht erneut storniert werden'); + } +} From 3b235db4f736a4e7d379ec8374e63c6105ba40a9 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 09:16:03 +0200 Subject: [PATCH 02/15] =?UTF-8?q?Fix:=20Stornorechnung=20=E2=80=93=20Synta?= =?UTF-8?q?xfehler=20PDF,=20Betreff=20vom=20Original,=20Referenzzeile=20un?= =?UTF-8?q?ter=20Titel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Models/Invoice.php | 2 +- src/Services/PdfInvoiceService.php | 16 ++++++++++------ src/Views/invoices/show.php | 9 ++++++++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 5e98207..50c9358 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -205,7 +205,7 @@ class Invoice VALUES (?, ?, ?, ?, ?, \'Finalisiert\', ?, ?, ?)', [ (int)$original['customer_id'], - 'Stornorechnung zu Rechnung ' . $original['rechnungs_nr'], + $original['betreff'], $original['leistungszeitraum'], $today, $today, diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index d6b9bcf..d465cb9 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -106,10 +106,10 @@ class PdfInvoiceService $isStorno = !empty($inv['storno_of_id']); $docTitle = $isStorno ? 'STORNORECHNUNG' : 'Rechnung'; - // Referenzzeile für Stornorechnung + // Referenzzeile direkt unter dem Dokumententitel (rechte Spalte im Adressblock) $stornoRefHtml = ''; if ($isStorno && !empty($inv['original_rechnungs_nr'])) { - $stornoRefHtml = '
' + $stornoRefHtml = '
' . 'Stornierung der Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '' . ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') . '
'; @@ -121,6 +121,10 @@ class PdfInvoiceService $fälligText = ' Fällig am: ' . $fmtDate($inv['faellig_datum']) . ''; } + $zahlungsHinweis = $isStorno ? '' : + '

Bitte überweisen Sie den Rechnungsbetrag innerhalb ' + . $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '

'; + // Positionszeilen $posRows = ''; foreach ($lines as $line) { @@ -167,12 +171,12 @@ class PdfInvoiceService
- {$docTitle} + + {$docTitle}{$stornoRefHtml} + - {$stornoRefHtml} -
@@ -225,7 +229,7 @@ class PdfInvoiceService
- {$isStorno ? '' : '

Bitte überweisen Sie den Rechnungsbetrag innerhalb ' . $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '

'} + {$zahlungsHinweis} HTML; diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 4df5dc3..3952a9f 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -102,7 +102,14 @@ $badgeClass = match($invoice['status']) {
-
+
+ + +
+ Stornierung der Rechnung Nr. +
+ +
From 3fcc4e6e269c5965e091121de4fc535eb0671f77 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 10:11:37 +0200 Subject: [PATCH 03/15] Fix: Stornorechnung-Referenzzeile vereinheitlicht (Vorschau = PDF) --- src/Controllers/InvoiceController.php | 2 +- src/Services/PdfInvoiceService.php | 4 ++-- src/Views/invoices/show.php | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 87baf15..890674f 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -114,7 +114,7 @@ class InvoiceController $originalrechnung = null; if (!empty($invoice['storno_of_id'])) { $originalrechnung = \Steuerkram\Database::fetchOne( - 'SELECT id, rechnungs_nr FROM invoices WHERE id = ?', + 'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?', [(int)$invoice['storno_of_id']] ); } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index d465cb9..4875497 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -110,8 +110,8 @@ class PdfInvoiceService $stornoRefHtml = ''; if ($isStorno && !empty($inv['original_rechnungs_nr'])) { $stornoRefHtml = '
' - . 'Stornierung der Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '' - . ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') + . 'zu Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '
' + . 'vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') . '
'; } diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 3952a9f..0b0607e 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -106,7 +106,8 @@ $badgeClass = match($invoice['status']) {
- Stornierung der Rechnung Nr. + zu Rechnung Nr.
+ vom
From 9d819b491cbd48d436ed852bddaa1744ef8331bb Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 10:16:45 +0200 Subject: [PATCH 04/15] =?UTF-8?q?Fix:=20Vorschau-Titel=20gr=C3=B6=C3=9Fer?= =?UTF-8?q?=20(20pt),=20Referenzzeile=20einzeilig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Services/PdfInvoiceService.php | 4 ++-- src/Views/invoices/show.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 4875497..720c054 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -110,8 +110,8 @@ class PdfInvoiceService $stornoRefHtml = ''; if ($isStorno && !empty($inv['original_rechnungs_nr'])) { $stornoRefHtml = '
' - . 'zu Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '
' - . 'vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') + . 'zu Rechnung Nr. ' . $h($inv['original_rechnungs_nr']) . '' + . ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '') . '
'; } diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 0b0607e..fb1f6e4 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -102,11 +102,11 @@ $badgeClass = match($invoice['status']) {
-
+
- zu Rechnung Nr.
+ zu Rechnung Nr. vom
From ccaee6190b5ab70cac38052c3f9a23098606897c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 10:18:37 +0200 Subject: [PATCH 05/15] Fix: Vorschau-Titel auf 17pt --- src/Views/invoices/show.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index fb1f6e4..5719302 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -102,7 +102,7 @@ $badgeClass = match($invoice['status']) {
-
+
From 99cf3dd25bc320770e63da094c34e5bc4b29bbba Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 13:54:07 +0200 Subject: [PATCH 06/15] =?UTF-8?q?Test:=20Stornorechnung=20End-to-End-Verif?= =?UTF-8?q?ikation=20f=C3=BCr=20UStVA=20und=20E=C3=9CR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/27_StornorechnungTest.php | 161 ++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 28 deletions(-) diff --git a/tests/27_StornorechnungTest.php b/tests/27_StornorechnungTest.php index 8bba073..d534951 100644 --- a/tests/27_StornorechnungTest.php +++ b/tests/27_StornorechnungTest.php @@ -4,13 +4,31 @@ declare(strict_types=1); /** * Test 27: Stornorechnung erstellen via Invoice::createCancellation(). + * + * Prüft End-to-End: + * - DB-Struktur (storno_of_id, negierte Beträge, Status) + * - Stornobuchungen werden angelegt + * - UStVA: Kz 81/81-USt heben sich nach Storno auf + * - EÜR: Erlös-Konto-Saldo nach Storno = vorheriger Stand */ class StornorechnungTest extends TestCase { - protected string $testName = 'Test 27: Stornorechnung erstellen'; + protected string $testName = 'Test 27: Stornorechnung – DB, Buchungen, UStVA, EÜR'; public function run(): void { + $year = (int)date('Y'); + $month = (int)date('n'); + $datum = date('Y-m-d'); + + $zahlungsId = $this->findAccountId('1200'); // Bank + $kategorieId = $this->findAccountId('8400'); // Erlöse 19% (ustva_kz=81) + + if (!$zahlungsId || !$kategorieId) { + $this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + // Testkunden anlegen $customerId = \Steuerkram\Models\Customer::create([ 'nachname' => 'StornoTester', @@ -23,11 +41,11 @@ class StornorechnungTest extends TestCase 'payment_days' => 14, ]); - // Originalrechnung erstellen und finalisieren + // ── 1. Originalrechnung erstellen + finalisieren ────────────── $originalId = \Steuerkram\Models\Invoice::create([ 'customer_id' => $customerId, 'betreff' => 'Testrechnung für Storno', - 'rechnungs_datum' => date('Y-m-d'), + 'rechnungs_datum' => $datum, 'lines' => [ [ 'bezeichnung' => 'Beratung', @@ -48,53 +66,140 @@ class StornorechnungTest extends TestCase \Steuerkram\Models\Invoice::finalize($originalId); $original = \Steuerkram\Models\Invoice::find($originalId); - $this->assertNotEmpty($original['rechnungs_nr'], 'Original hat Rechnungsnummer'); $this->assertEquals('Finalisiert', $original['status'], 'Original ist finalisiert'); $this->assertEquals(250.00, round((float)$original['betrag_netto'], 2), 'Original Nettobetrag korrekt'); - // Stornorechnung erstellen + // ── 2. Beleg + Buchung manuell anlegen (sonst keine Stornowirkung) ── + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => \Steuerkram\Models\Beleg::generateBelegNr(), + 'filename' => $original['rechnungs_nr'] . '.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $original['rechnungs_nr'] . uniqid()), + 'beleg_datum' => $datum, + 'rechnungs_nr' => $original['rechnungs_nr'], + 'betrag_brutto' => $original['betrag_brutto'], + 'belegtyp' => 'Ausgangsrechnung', + ]); + \Steuerkram\Database::execute( + 'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId] + ); + + $origBookingId = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $kategorieId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 250.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test Originalrechnung', + ]); + $origBooking = \Steuerkram\Models\Booking::find($origBookingId); + $this->assertEquals(250.00, round((float)$origBooking['betrag_netto'], 2), 'Original-Buchung Netto = 250'); + $this->assertEquals(47.50, round((float)$origBooking['betrag_ust'], 2), 'Original-Buchung USt = 47,50'); + + // ── 3. UStVA + EÜR Stand VOR Stornorechnung erfassen ────────── + $ustvaCalc = new \Steuerkram\Services\UstvaCalculator(); + $kzPre = $ustvaCalc->calculate($year, $month); + $kz81NettoPre = (float)($kzPre['81']['betrag'] ?? 0); + $kz83UstPre = (float)($kzPre['83']['steuer'] ?? 0); + + $euerCalc = new \Steuerkram\Services\EuerCalculator(); + $euerPre = $euerCalc->calculate($year); + $kontoNettoPre = 0.0; + foreach ($euerPre['einnahmen'] as $row) { + if ((int)$row['id'] === (int)$kategorieId) { + $kontoNettoPre = (float)$row['netto']; + break; + } + } + + // Sanity-Check: Originalbuchung ist in den Pre-Werten enthalten + $this->assert($kz81NettoPre >= 250.0, 'UStVA Kz 81 Pre enthält Original-Netto (≥ 250)'); + $this->assert($kz83UstPre >= 47.50, 'UStVA Kz 83 Pre enthält Original-USt (≥ 47,50)'); + $this->assert($kontoNettoPre >= 250.0, 'EÜR Pre: Erlös-Konto enthält Originalbuchung (≥ 250)'); + + // ── 4. Stornorechnung erstellen ─────────────────────────────── $stornoId = \Steuerkram\Models\Invoice::createCancellation($originalId); $this->assertGreaterThan(0, $stornoId, 'createCancellation gibt gültige ID zurück'); - // Stornorechnung prüfen $storno = \Steuerkram\Models\Invoice::find($stornoId); - $this->assertNotEmpty($storno, 'Stornorechnung gefunden'); $this->assertEquals($originalId, (int)$storno['storno_of_id'], 'storno_of_id zeigt auf Original'); - $this->assertEquals('Finalisiert', $storno['status'], 'Stornorechnung ist sofort finalisiert'); - $this->assertNotEmpty($storno['rechnungs_nr'], 'Stornorechnung hat Rechnungsnummer'); - $this->assert($original['rechnungs_nr'] !== $storno['rechnungs_nr'], 'Rechnungsnummern sind verschieden'); - - // Beträge sind negiert - $this->assertEquals(-250.00, round((float)$storno['betrag_netto'], 2), 'Nettobetrag negiert'); - $this->assertEquals(-47.50, round((float)$storno['betrag_ust'], 2), 'USt-Betrag negiert'); - $this->assertEquals(-297.50, round((float)$storno['betrag_brutto'], 2), 'Bruttobetrag negiert'); + $this->assertEquals('Finalisiert', $storno['status'], 'Stornorechnung ist Finalisiert'); + $this->assert($original['rechnungs_nr'] !== $storno['rechnungs_nr'], 'Rechnungsnummern verschieden'); + $this->assertEquals(-250.00, round((float)$storno['betrag_netto'], 2), 'Storno Netto negiert'); + $this->assertEquals(-47.50, round((float)$storno['betrag_ust'], 2), 'Storno USt negiert'); + $this->assertEquals(-297.50, round((float)$storno['betrag_brutto'], 2), 'Storno Brutto negiert'); // Originalrechnung ist storniert $originalNach = \Steuerkram\Models\Invoice::find($originalId); $this->assertEquals('Storniert', $originalNach['status'], 'Originalrechnung hat Status Storniert'); - // Positionen der Stornorechnung sind negiert + // Positionen negiert $stornoLines = \Steuerkram\Models\Invoice::lines($stornoId); $this->assertEquals(2, count($stornoLines), 'Stornorechnung hat 2 Positionen'); foreach ($stornoLines as $line) { if (($line['type'] ?? 'position') === 'position') { - $this->assert((float)$line['menge'] > 0, 'Menge bleibt positiv'); - $this->assert((float)$line['einzelpreis'] < 0, 'Einzelpreis ist negativ'); + $this->assert((float)$line['menge'] > 0, 'Menge bleibt positiv'); + $this->assert((float)$line['einzelpreis'] < 0, 'Einzelpreis ist negativ'); + $this->assert((float)$line['betrag_netto'] < 0, 'Position Netto < 0'); + $this->assert((float)$line['betrag_ust'] < 0, 'Position USt < 0'); } } - // Stornobuchungen müssen angelegt worden sein (Original hatte beleg_id nach finalize) - $originalMitBeleg = \Steuerkram\Models\Invoice::find($originalId); - if (!empty($originalMitBeleg['beleg_id'])) { - $bookings = \Steuerkram\Database::fetchAll( - 'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 1', - [(int)$originalMitBeleg['beleg_id']] - ); - $this->assertGreaterThan(0, count($bookings), 'Stornobuchungen wurden angelegt'); - } + // ── 5. Stornobuchung muss am Originalbeleg hängen ───────────── + $reversalRows = \Steuerkram\Database::fetchAll( + 'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1', + [$belegId] + ); + $this->assertEquals(1, count($reversalRows), 'Genau eine Stornobuchung am Originalbeleg'); + $reversal = $reversalRows[0]; + $this->assertEquals(-250.00, round((float)$reversal['betrag_netto'], 2), 'Stornobuchung Netto = -250'); + $this->assertEquals(-47.50, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung USt = -47,50'); + $this->assertEquals(-297.50, round((float)$reversal['betrag_brutto'], 2), 'Stornobuchung Brutto = -297,50'); - // Erneutes Stornieren der Stornorechnung muss fehlschlagen + // Original-Buchung verweist auf Stornobuchung (reversed_by) + $origNach = \Steuerkram\Models\Booking::find($origBookingId); + $this->assertEquals( + (int)$reversal['id'], + (int)$origNach['reversed_by'], + 'Originalbuchung hat reversed_by = Stornobuchungs-ID' + ); + + // ── 6. UStVA Kz 81 + 83 müssen sich aufheben ────────────────── + $kzPost = $ustvaCalc->calculate($year, $month); + $kz81NettoPost = (float)($kzPost['81']['betrag'] ?? 0); + $kz83UstPost = (float)($kzPost['83']['steuer'] ?? 0); + + $this->assertEquals( + round($kz81NettoPre - 250.00, 2), + round($kz81NettoPost, 2), + 'UStVA Kz 81 (Netto): Original + Storno heben sich auf' + ); + $this->assertEquals( + round($kz83UstPre - 47.50, 2), + round($kz83UstPost, 2), + 'UStVA Kz 83 (USt): Original + Storno heben sich auf' + ); + + // ── 7. EÜR Erlös-Konto muss sich aufheben ───────────────────── + $euerPost = $euerCalc->calculate($year); + $kontoNettoPost = 0.0; + foreach ($euerPost['einnahmen'] as $row) { + if ((int)$row['id'] === (int)$kategorieId) { + $kontoNettoPost = (float)$row['netto']; + break; + } + } + $this->assertEquals( + round($kontoNettoPre - 250.00, 2), + round($kontoNettoPost, 2), + 'EÜR: Original + Storno heben sich auf Erlös-Konto auf' + ); + + // ── 8. Erneutes Stornieren der Stornorechnung scheitert ─────── $exception = false; try { \Steuerkram\Models\Invoice::createCancellation($stornoId); From 994eb95f70823808af9998282b298abcffb926c7 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:14:43 +0200 Subject: [PATCH 07/15] Feature: Zahlungs-Modal in Rechnungsdetailansicht + Invoice::createBookingsFromLines() --- public/index.php | 1 + src/Controllers/InvoiceController.php | 41 +++++++- src/Models/Invoice.php | 33 +++++++ src/Views/invoices/show.php | 133 +++++++++++++++++++++++++- tests/27_StornorechnungTest.php | 15 +-- 5 files changed, 210 insertions(+), 13 deletions(-) diff --git a/public/index.php b/public/index.php index dc3b910..422201f 100644 --- a/public/index.php +++ b/public/index.php @@ -104,6 +104,7 @@ $router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->dest $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); $router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p)); +$router->post('/invoices/{id}/quick-book', fn($p) => (new InvoiceController())->quickBook($p)); // ── Anlagevermögen ─────────────────────────────────────────── $router->get('/assets', fn($p) => (new AssetController())->index($p)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 890674f..81045c2 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -119,6 +119,14 @@ class InvoiceController ); } + // Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden) + $bookings = []; + $accounts = []; + if (!empty($invoice['beleg_id'])) { + $bookings = Booking::forBeleg((int)$invoice['beleg_id']); + $accounts = Account::all(['aktiv' => 1]); + } + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'), @@ -128,6 +136,8 @@ class InvoiceController 'settings' => $settings, 'stornorechnung' => $stornorechnung ?: null, 'originalrechnung' => $originalrechnung, + 'bookings' => $bookings, + 'accounts' => $accounts, ]), ]); } @@ -340,6 +350,10 @@ class InvoiceController } if ($errors) { + if (!empty($data['_from_modal'])) { + View::redirect("/invoices/{$id}", implode(' ', $errors), 'error'); + return; + } $bookings = Booking::forBeleg($belegId); $accounts = Account::all(['aktiv' => 1]); $view = new View(); @@ -360,7 +374,8 @@ class InvoiceController $data['beleg_id'] = $belegId; $data['buchungstyp'] = 'Einnahme'; Booking::create($data); - View::redirect("/invoices/{$id}/book", 'Buchung erfasst.'); + $returnUrl = !empty($data['_from_modal']) ? "/invoices/{$id}" : "/invoices/{$id}/book"; + View::redirect($returnUrl, 'Buchung erfasst.'); } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); $bookings = Booking::forBeleg($belegId); @@ -393,6 +408,30 @@ class InvoiceController } } + public function quickBook(array $params): void + { + $id = (int)$params['id']; + $invoice = Invoice::find($id); + if (!$invoice) { http_response_code(404); return; } + + $data = $_POST; + if (empty($data['kategorie_konto_id']) || empty($data['zahlungskonto_id']) || empty($data['buchungs_datum'])) { + View::redirect("/invoices/{$id}", 'Bitte Konto und Datum angeben.', 'error'); + return; + } + try { + $cnt = count(Invoice::createBookingsFromLines( + $id, + (int)$data['kategorie_konto_id'], + (int)$data['zahlungskonto_id'], + $data['buchungs_datum'] + )); + View::redirect("/invoices/{$id}", "{$cnt} Buchung(en) automatisch erfasst."); + } catch (\RuntimeException $e) { + View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); + } + } + private function createBeleg(array $invoice): string { $id = (int)$invoice['id']; diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 50c9358..a82cc4b 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -162,6 +162,39 @@ class Invoice Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]); } + public static function createBookingsFromLines( + int $invoiceId, + int $kategorieKontoId, + int $zahlungskontoId, + string $buchungsDatum + ): array { + $inv = self::find($invoiceId); + if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.'); + if (empty($inv['beleg_id'])) throw new \RuntimeException('Rechnung hat keinen Beleg (noch nicht finalisiert?).'); + + $gruppen = []; + foreach (self::lines($invoiceId) as $line) { + if (($line['type'] ?? 'position') !== 'position') continue; + $satz = (float)$line['ust_satz']; + $gruppen[$satz] = ($gruppen[$satz] ?? 0.0) + round((float)$line['betrag_netto'], 2); + } + + $ids = []; + foreach ($gruppen as $satz => $netto) { + $ids[] = Booking::create([ + 'beleg_id' => (int)$inv['beleg_id'], + 'buchungs_datum' => $buchungsDatum, + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $kategorieKontoId, + 'zahlungskonto_id' => $zahlungskontoId, + 'betrag_netto' => round($netto, 2), + 'ust_satz' => $satz, + 'buchungstext' => 'Ausgangsrechnung ' . $inv['rechnungs_nr'], + ]); + } + return $ids; + } + public static function cancel(int $id): void { $invoice = self::find($id); diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 5719302..a2340c2 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -50,8 +50,9 @@ $badgeClass = match($invoice['status']) { - - Zahlungseingang erfassen + +
+ + + + + + + diff --git a/tests/27_StornorechnungTest.php b/tests/27_StornorechnungTest.php index d534951..73bceaa 100644 --- a/tests/27_StornorechnungTest.php +++ b/tests/27_StornorechnungTest.php @@ -86,16 +86,11 @@ class StornorechnungTest extends TestCase 'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId] ); - $origBookingId = \Steuerkram\Models\Booking::create([ - 'beleg_id' => $belegId, - 'buchungs_datum' => $datum, - 'buchungstyp' => 'Einnahme', - 'kategorie_konto_id' => $kategorieId, - 'zahlungskonto_id' => $zahlungsId, - 'betrag_netto' => 250.00, - 'ust_satz' => 19.0, - 'buchungstext' => 'Test Originalrechnung', - ]); + $bookingIds = \Steuerkram\Models\Invoice::createBookingsFromLines( + $originalId, $kategorieId, $zahlungsId, $datum + ); + $this->assertGreaterThan(0, count($bookingIds), 'createBookingsFromLines erzeugt Buchungen'); + $origBookingId = $bookingIds[0]; $origBooking = \Steuerkram\Models\Booking::find($origBookingId); $this->assertEquals(250.00, round((float)$origBooking['betrag_netto'], 2), 'Original-Buchung Netto = 250'); $this->assertEquals(47.50, round((float)$origBooking['betrag_ust'], 2), 'Original-Buchung USt = 47,50'); From e62fbe9efee8372f4f68ad11bf396f1536ad12dd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:29:50 +0200 Subject: [PATCH 08/15] Fix: Beleg referenziert Rechnung via invoice_id, serveFile leitet auf Rechnungs-PDF weiter --- migrations/043_belege_invoice_id.sql | 6 ++++++ src/Controllers/BelegController.php | 6 ++++++ src/Controllers/InvoiceController.php | 1 + src/Models/Beleg.php | 7 +++++-- 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 migrations/043_belege_invoice_id.sql diff --git a/migrations/043_belege_invoice_id.sql b/migrations/043_belege_invoice_id.sql new file mode 100644 index 0000000..5272a79 --- /dev/null +++ b/migrations/043_belege_invoice_id.sql @@ -0,0 +1,6 @@ +ALTER TABLE belege + ADD COLUMN invoice_id INT UNSIGNED NULL DEFAULT NULL + COMMENT 'FK zur zugehörigen Ausgangsrechnung (wenn kein physischer Beleg)' + AFTER id, + ADD CONSTRAINT fk_belege_invoice + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL; diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index e34fb1d..471cbfb 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -230,6 +230,12 @@ class BelegController $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } + // Beleg ohne physische Datei → PDF der verknüpften Rechnung ausliefern + if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) { + header('Location: /invoices/' . (int)$beleg['invoice_id'] . '/pdf'); + exit; + } + $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; if (!file_exists($absPath)) { http_response_code(404); return; } diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 81045c2..c6ad0b2 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -438,6 +438,7 @@ class InvoiceController $belegNr = Beleg::generateBelegNr(); $belegId = Beleg::create([ + 'invoice_id' => $id, 'beleg_nr' => $belegNr, 'filename' => $invoice['rechnungs_nr'] . '.pdf', 'storage_path' => '', diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index 4004430..080e3d5 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -100,11 +100,14 @@ class Beleg $fyId = $fy['id']; } + $invoiceId = isset($data['invoice_id']) && $data['invoice_id'] ? (int)$data['invoice_id'] : null; + Database::execute( - 'INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, + 'INSERT INTO belege (invoice_id, beleg_nr, filename, storage_path, mime_type, file_hash, beleg_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + $invoiceId, $data['beleg_nr'], $data['filename'], $data['storage_path'], From 8e0c73289a2d489bb481934571400c921f98b363 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:35:07 +0200 Subject: [PATCH 09/15] =?UTF-8?q?Fix:=20Beleg-Detailansicht=20zeigt=20Link?= =?UTF-8?q?=20zur=20verkn=C3=BCpften=20Ausgangsrechnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Views/belege/detail.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 2660bea..a482cad 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -71,6 +71,14 @@ $suppliers = $suppliers ?? []; –' ?>
+ +
+ Ausgangsrechnung
+ + + +
+
Brutto (Buchungen)
' . $fmt($beleg['betrag_brutto']) . '' : '–' ?> From d72f9077a09c8a3495beedd803b7154a55eed33b Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:37:01 +0200 Subject: [PATCH 10/15] Fix: $payDays vor erster Verwendung in PdfInvoiceService definieren --- src/Services/PdfInvoiceService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 720c054..fc16e8a 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -115,6 +115,8 @@ class PdfInvoiceService . '
'; } + $payDays = $inv['payment_days'] ?? 14; + // Fälligkeitsdatum (nicht bei Stornorechnung) $fälligText = ''; if (!$isStorno && !empty($inv['faellig_datum'])) { @@ -145,8 +147,6 @@ class PdfInvoiceService } } - $payDays = $inv['payment_days'] ?? 14; - // Empfänger-Land nur wenn nicht DE $land = ($inv['land'] ?? 'DE') !== 'DE' ? '
' . $h($inv['land']) : ''; From 81682841a8c800f2c84e3df4a1ec36b51b0cf455 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:40:42 +0200 Subject: [PATCH 11/15] Fix: Rechnungs-PDF in Belegansicht inline anzeigen statt herunterladen --- src/Controllers/BelegController.php | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 471cbfb..809d885 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -230,9 +230,28 @@ class BelegController $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } - // Beleg ohne physische Datei → PDF der verknüpften Rechnung ausliefern + // Beleg ohne physische Datei → PDF der verknüpften Rechnung inline ausliefern if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) { - header('Location: /invoices/' . (int)$beleg['invoice_id'] . '/pdf'); + $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; } From 58806757fa52c79f1518db481a0568d6943e6953 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 15:48:43 +0200 Subject: [PATCH 12/15] =?UTF-8?q?Fix:=20Rechnungs-PDF=20beim=20Finalisiere?= =?UTF-8?q?n=20unver=C3=A4nderlich=20speichern=20(GoBD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Controllers/InvoiceController.php | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index c6ad0b2..37dcd64 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -434,16 +434,31 @@ class InvoiceController private function createBeleg(array $invoice): string { - $id = (int)$invoice['id']; + $id = (int)$invoice['id']; + $lines = Invoice::lines($id); + $settings = Setting::get(); + + // PDF zum Zeitpunkt der Finalisierung unveränderlich generieren und speichern (GoBD) + $svc = new PdfInvoiceService(); + $pdf = $svc->renderToString($invoice, $lines, $settings); $belegNr = Beleg::generateBelegNr(); + $year = date('Y', strtotime($invoice['rechnungs_datum'])); + $relDir = "static/uploads/{$year}/{$belegNr}"; + $absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; + if (!is_dir($absDir)) mkdir($absDir, 0755, true); + + $filename = $invoice['rechnungs_nr'] . '.pdf'; + $storagePath = "{$relDir}/original.pdf"; + file_put_contents(dirname(__DIR__, 2) . "/public/{$storagePath}", $pdf); + $belegId = Beleg::create([ 'invoice_id' => $id, 'beleg_nr' => $belegNr, - 'filename' => $invoice['rechnungs_nr'] . '.pdf', - 'storage_path' => '', + 'filename' => $filename, + 'storage_path' => $storagePath, 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', $invoice['rechnungs_nr']), + 'file_hash' => hash('sha256', $pdf), 'beleg_datum' => $invoice['rechnungs_datum'], 'rechnungs_nr' => $invoice['rechnungs_nr'], 'betrag_brutto' => $invoice['betrag_brutto'], From 8c9d109e56ddf8b1e29f090c851f13a52076e916 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 16:25:30 +0200 Subject: [PATCH 13/15] Fix: PDF-Download liefert gespeicherten Beleg statt neu generierter PDF --- src/Controllers/InvoiceController.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 37dcd64..a58b0b9 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -271,10 +271,25 @@ class InvoiceController public function pdf(array $params): void { - $invoice = Invoice::find((int)$params['id']); + $invoice = Invoice::find((int)$params['id']); if (!$invoice) { http_response_code(404); return; } - // Bei Stornorechnung Originalrechnung laden für Referenzzeile + // Gespeicherte PDF aus dem Beleg ausliefern (GoBD: unveränderlicher Stand) + if (!empty($invoice['beleg_id'])) { + $beleg = Beleg::find((int)$invoice['beleg_id']); + $absPath = $beleg + ? dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'] + : ''; + if ($absPath && file_exists($absPath)) { + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="' . $beleg['filename'] . '"'); + header('Content-Length: ' . filesize($absPath)); + readfile($absPath); + return; + } + } + + // Fallback: PDF on-the-fly generieren (Entwurf oder Beleg noch ohne Datei) if (!empty($invoice['storno_of_id'])) { $original = Invoice::find((int)$invoice['storno_of_id']); if ($original) { From 9f1557ae662e9cee8d5dfea1eed1ea35d600a73b Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 16:34:33 +0200 Subject: [PATCH 14/15] =?UTF-8?q?Fix:=20Stornorechnung-PDF=20beim=20Erstel?= =?UTF-8?q?len=20unver=C3=A4nderlich=20speichern=20(GoBD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Controllers/InvoiceController.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index a58b0b9..7a3f4c8 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -453,6 +453,15 @@ class InvoiceController $lines = Invoice::lines($id); $settings = Setting::get(); + // Bei Stornorechnung Originaldaten für Referenzzeile ergänzen + if (!empty($invoice['storno_of_id'])) { + $original = Invoice::find((int)$invoice['storno_of_id']); + if ($original) { + $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; + $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; + } + } + // PDF zum Zeitpunkt der Finalisierung unveränderlich generieren und speichern (GoBD) $svc = new PdfInvoiceService(); $pdf = $svc->renderToString($invoice, $lines, $settings); @@ -478,7 +487,7 @@ class InvoiceController 'rechnungs_nr' => $invoice['rechnungs_nr'], 'betrag_brutto' => $invoice['betrag_brutto'], 'belegtyp' => 'Ausgangsrechnung', - 'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'], + 'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'], ]); \Steuerkram\Database::execute( @@ -508,6 +517,10 @@ class InvoiceController public function cancel(array $params): void { $stornoId = Invoice::createCancellation((int)$params['id']); + $storno = Invoice::find($stornoId); + if ($storno) { + $this->createBeleg($storno); + } View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.'); } From e33fb4252e5e91019d1bb789a7fc676209f8d4b6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 17:05:41 +0200 Subject: [PATCH 15/15] =?UTF-8?q?Fix:=20Storno-Button=20in=20Rechnungs?= =?UTF-8?q?=C3=BCbersicht=20f=C3=BCr=20Stornorechnungen=20ausblenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Views/invoices/list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Views/invoices/list.php b/src/Views/invoices/list.php index 4dd373b..f84e6a1 100644 --- a/src/Views/invoices/list.php +++ b/src/Views/invoices/list.php @@ -64,7 +64,7 @@ PDF - +