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'); + } +}