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/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/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/BelegController.php b/src/Controllers/BelegController.php index e34fb1d..809d885 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -230,6 +230,31 @@ class BelegController $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } + // Beleg ohne physische Datei → PDF der verknüpften Rechnung inline ausliefern + if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) { + $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; + } + $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 fff9f91..7a3f4c8 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -104,13 +104,40 @@ 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, rechnungs_datum FROM invoices WHERE id = ?', + [(int)$invoice['storno_of_id']] + ); + } + + // 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'), 'content' => $this->renderView('invoices/show', [ - 'invoice' => $invoice, - 'lines' => $lines, - 'settings' => $settings, + 'invoice' => $invoice, + 'lines' => $lines, + 'settings' => $settings, + 'stornorechnung' => $stornorechnung ?: null, + 'originalrechnung' => $originalrechnung, + 'bookings' => $bookings, + 'accounts' => $accounts, ]), ]); } @@ -244,9 +271,33 @@ 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; } + // 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) { + $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; + $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; + } + } + $lines = Invoice::lines((int)$params['id']); $settings = Setting::get(); @@ -314,6 +365,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(); @@ -334,7 +389,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); @@ -367,22 +423,71 @@ 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']; + $id = (int)$invoice['id']; + $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); $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'], '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( @@ -411,8 +516,12 @@ 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']); + $storno = Invoice::find($stornoId); + if ($storno) { + $this->createBeleg($storno); + } + View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.'); } public function addLine(array $params): void 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'], diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 821fc6a..a82cc4b 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 @@ -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); @@ -181,6 +214,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'], + $original['betreff'], + $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..fc16e8a 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,29 @@ 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 direkt unter dem Dokumententitel (rechte Spalte im Adressblock) + $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 . '
'; // Positionszeilen $posRows = ''; @@ -127,8 +147,6 @@ class PdfInvoiceService } } - $payDays = $inv['payment_days'] ?? 14; - // Empfänger-Land nur wenn nicht DE $land = ($inv['land'] ?? 'DE') !== 'DE' ? '- Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText} -
+ + {$zahlungsHinweis} HTML; 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 ?? []; = $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '–' ?> + + +