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 = '
- 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 + + +