From 934532fac97b0a09203d5502827311b8cf7a2501 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Fri, 31 Jul 2026 20:01:32 +0200 Subject: [PATCH] Fix #zahlungsstatus: Rechnungsstatus folgt automatisch den erfassten Zahlungen --- Changes.md | 11 +++ docs/funktionen.md | 4 +- migrations/090_invoice_zahlstatus.sql | 32 +++++++ public/static/css/app.css | 1 + src/Controllers/AccountController.php | 10 +- src/Controllers/BookingController.php | 4 + src/Controllers/InvoiceController.php | 7 +- src/Models/Booking.php | 38 ++++++-- src/Models/Invoice.php | 61 ++++++++++++- src/Views/customers/show.php | 10 +- src/Views/invoices/book.php | 29 ++++-- src/Views/invoices/list.php | 12 ++- src/Views/invoices/show.php | 38 +++++++- tests/73_InvoiceZahlungsstatusTest.php | 121 +++++++++++++++++++++++++ 14 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 migrations/090_invoice_zahlstatus.sql create mode 100644 tests/73_InvoiceZahlungsstatusTest.php diff --git a/Changes.md b/Changes.md index 37563fb..d5539ee 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,17 @@ --- +## 2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen + +- Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: **Teilzahlung** (weniger als der Rechnungsbetrag), **Bezahlt** (exakt der Rechnungsbetrag) und **Überzahlt** (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück. +- **`migrations/090_invoice_zahlstatus.sql`** (neu): Erweitert das Status-ENUM um `Teilzahlung` und `Überzahlt`, ergänzt die Spalte `sent_at` (damit beim Zurücksetzen zwischen „Finalisiert" und „Gesendet" unterschieden werden kann) und rechnet den Status vorhandener Rechnungen einmalig aus deren Buchungen nach. Manuell auf „Bezahlt" gesetzte sowie stornierte Rechnungen bleiben unangetastet. +- **`src/Models/Invoice.php`:** Neue Methoden `recalcPaymentStatus()` (Statusableitung inkl. Vorzeichennormalisierung für Stornorechnungen mit negativen Beträgen) und `recalcPaymentStatusForBeleg()`. Entwürfe und stornierte Rechnungen werden nie überschrieben. +- **`src/Models/Booking.php`:** `create()` und `reverse()` stoßen die Neuberechnung nach dem Commit an, ebenso das Löschen einer Buchung im Testmodus (`BookingController::destroy()`). Damit greift die Statusberechnung unabhängig davon, ob über die Rechnung, den Beleg oder die Bankzuordnung gebucht wird. `sumBruttoForBeleg()` kann jetzt auf einen Buchungstyp eingeschränkt werden; `sumZahlungen()` summiert die aktiven Einnahme-Hauptbuchungen einer bereits geladenen Liste. +- **`src/Views/invoices/show.php`, `book.php`:** Das Zahlungs-Modal und die Buchungsansicht zeigen unter den Buchungen jetzt Rechnungsbetrag, gezahlten Betrag und den offenen Rest (bzw. den überzahlten Betrag). Die Betragsvorbelegung im Modal entspricht dem offenen Restbetrag statt immer dem vollen Rechnungsnetto, der USt-Satz wird aus der Rechnung vorbelegt. +- **Statusanzeige** in Rechnungsliste, Detailansicht, Buchungsansicht und Kundenakte um die neuen Status ergänzt (`badge-orange` neu für Teilzahlung, `badge-purple` für Überzahlt), Statusfilter der Rechnungsliste umfasst jetzt alle sieben Status. +- **`src/Controllers/AccountController.php`:** Die Kennzahl „offene Rechnungen" auf dem Dashboard berücksichtigt Teilzahlungen und weist nur noch den tatsächlich offenen Restbetrag aus. +- **`tests/73_InvoiceZahlungsstatusTest.php`** (neu): Deckt Teilzahlung, Vollzahlung, Überzahlung, Stornos und den Rückfall auf „Gesendet"/„Finalisiert" ab. + ## 2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht - Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (`Rechnung-.xml` bzw. `Stornorechnung-.xml`) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen. diff --git a/docs/funktionen.md b/docs/funktionen.md index d589d03..bbe78be 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -52,7 +52,9 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen - DIN 5008-konformes Layout, PDF-Export via mPDF - Rechnungsnummernformat **PPPYYYYMM-nn** (konfigurierbarer Präfix + Jahr + Monat + laufende Nummer, monatlicher Reset) -- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Bezahlt / Storniert +- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert +- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben. +- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag. - 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 - **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein. diff --git a/migrations/090_invoice_zahlstatus.sql b/migrations/090_invoice_zahlstatus.sql new file mode 100644 index 0000000..368d487 --- /dev/null +++ b/migrations/090_invoice_zahlstatus.sql @@ -0,0 +1,32 @@ +-- Zahlungsstatus für Ausgangsrechnungen: Teilzahlung / Überzahlt +ALTER TABLE invoices + MODIFY COLUMN status ENUM('Entwurf','Finalisiert','Gesendet','Teilzahlung','Bezahlt','Überzahlt','Storniert') + NOT NULL DEFAULT 'Entwurf'; + +-- Versandzeitpunkt, damit beim Zurücksetzen des Zahlungsstatus +-- zwischen 'Finalisiert' und 'Gesendet' unterschieden werden kann +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS sent_at DATETIME NULL DEFAULT NULL AFTER status; + +UPDATE invoices + SET sent_at = COALESCE(updated_at, created_at) + WHERE sent_at IS NULL + AND status IN ('Gesendet','Bezahlt'); + +-- Bestandsdaten: Status aus den bereits zugeordneten Buchungen ableiten. +-- Manuell auf 'Bezahlt' gesetzte sowie stornierte Rechnungen bleiben unangetastet. +UPDATE invoices i + JOIN (SELECT beleg_id, SUM(betrag_brutto) AS gezahlt + FROM bookings + WHERE buchungstyp = 'Einnahme' AND is_reversal = 0 + AND parent_booking_id IS NULL AND reversed_by IS NULL + GROUP BY beleg_id) b ON b.beleg_id = i.beleg_id + SET i.status = CASE + WHEN ROUND(i.betrag_brutto - b.gezahlt, 2) = 0 THEN 'Bezahlt' + WHEN (i.betrag_brutto > 0 AND b.gezahlt > i.betrag_brutto) + OR (i.betrag_brutto < 0 AND b.gezahlt < i.betrag_brutto) THEN 'Überzahlt' + WHEN (i.betrag_brutto > 0 AND b.gezahlt > 0) + OR (i.betrag_brutto < 0 AND b.gezahlt < 0) THEN 'Teilzahlung' + ELSE i.status + END + WHERE i.status IN ('Finalisiert','Gesendet'); diff --git a/public/static/css/app.css b/public/static/css/app.css index ddaa19d..6c92d13 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -336,6 +336,7 @@ td { padding: .65rem 1rem; vertical-align: middle; } .badge-blue { background: #dbeafe; color: #1e40af; } .badge-gray { background: #f1f5f9; color: #475569; } .badge-purple { background: #ede9fe; color: #6d28d9; } +.badge-orange { background: #ffedd5; color: #9a3412; } .kuerzel-badge { display: inline-block; diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php index b691986..a139995 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -78,9 +78,15 @@ class AccountController extends BaseController $stats['vormonat_label'] = $monthNames[$prevMonth - 1]; // Offene Rechnungen + // Bei Teilzahlungen zählt nur der noch offene Restbetrag $offen = \Steuerkram\Database::fetchOne( - 'SELECT COUNT(*) as cnt, COALESCE(SUM(betrag_brutto),0) as total - FROM invoices WHERE status=\'Gesendet\'' + 'SELECT COUNT(*) as cnt, + COALESCE(SUM(i.betrag_brutto - COALESCE(( + SELECT SUM(bk.betrag_brutto) FROM bookings bk + WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\' + AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL + ), 0)), 0) as total + FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')' ); $stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0); $stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0); diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index ae84636..559824c 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -9,6 +9,7 @@ use Steuerkram\Models\Account; use Steuerkram\Models\Beleg; use Steuerkram\Models\BankTransaction; use Steuerkram\Models\FiscalYear; +use Steuerkram\Models\Invoice; use Steuerkram\Models\Setting; use Steuerkram\View; @@ -300,6 +301,9 @@ class BookingController extends BaseController \Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]); \Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]); + if (!empty($booking['beleg_id'])) { + Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']); + } $redirect = !empty($_POST['beleg_id']) ? '/belege/' . (int)$_POST['beleg_id'] : '/journal'; diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 31d3caa..6b16b5a 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -277,7 +277,12 @@ class InvoiceController extends BaseController if ($invoice['status'] === 'Finalisiert') { \Steuerkram\Database::execute( - "UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?", + "UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?", + [$id] + ); + } else { + \Steuerkram\Database::execute( + 'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL', [$id] ); } diff --git a/src/Models/Booking.php b/src/Models/Booking.php index b9f9987..591ee32 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -234,6 +234,8 @@ class Booking throw $e; } + Invoice::recalcPaymentStatusForBeleg((int)$data['beleg_id']); + return $mainId; } @@ -326,6 +328,10 @@ class Booking throw $e; } + if (!empty($reversalData['beleg_id'])) { + Invoice::recalcPaymentStatusForBeleg((int)$reversalData['beleg_id']); + } + return $reversalId; } @@ -431,15 +437,31 @@ class Booking return sprintf('BU-%s-%06d', $year, $next); } - /** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs. */ - public static function sumBruttoForBeleg(int $belegId): float + /** Summiert betrag_brutto der aktiven Einnahme-Hauptbuchungen einer bereits geladenen Buchungsliste. */ + public static function sumZahlungen(array $bookings): float { - $row = Database::fetchOne( - 'SELECT COALESCE(SUM(betrag_brutto), 0) AS total - FROM bookings - WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL AND reversed_by IS NULL', - [$belegId] - ); + $sum = 0.0; + foreach ($bookings as $bk) { + if ($bk['parent_booking_id'] !== null) continue; + if ((bool)$bk['is_reversal'] || !empty($bk['reversed_by'])) continue; + if (($bk['buchungstyp'] ?? '') !== Constants::BUCHUNGSTYP_EINNAHME) continue; + $sum += (float)$bk['betrag_brutto']; + } + return round($sum, 2); + } + + /** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs, optional nur eines Buchungstyps. */ + public static function sumBruttoForBeleg(int $belegId, ?string $buchungstyp = null): float + { + $sql = 'SELECT COALESCE(SUM(betrag_brutto), 0) AS total + FROM bookings + WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL AND reversed_by IS NULL'; + $params = [$belegId]; + if ($buchungstyp !== null) { + $sql .= ' AND buchungstyp = ?'; + $params[] = $buchungstyp; + } + $row = Database::fetchOne($sql, $params); return round((float)($row['total'] ?? 0), 2); } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 6c0c5e2..b801ebf 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -162,6 +162,65 @@ class Invoice Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]); } + /** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */ + public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt']; + + /** + * Leitet den Zahlungsstatus einer Rechnung aus der Summe der zugeordneten + * Buchungen ab: keine Zahlung → Finalisiert/Gesendet, zu wenig → Teilzahlung, + * exakt → Bezahlt, zu viel → Überzahlt. + * Entwürfe und stornierte Rechnungen bleiben unangetastet. + */ + public static function recalcPaymentStatus(int $invoiceId): ?string + { + $inv = Database::fetchOne( + 'SELECT id, beleg_id, betrag_brutto, status, sent_at FROM invoices WHERE id = ?', + [$invoiceId] + ); + if (!$inv) return null; + if (!in_array($inv['status'], self::ZAHL_STATUS, true)) return $inv['status']; + if (empty($inv['beleg_id'])) return $inv['status']; + + $soll = round((float)$inv['betrag_brutto'], 2); + $ist = Booking::sumBruttoForBeleg((int)$inv['beleg_id'], 'Einnahme'); + + // Stornorechnungen haben negative Beträge – Vorzeichen normalisieren + if ($soll < 0) { + $soll = -$soll; + $ist = -$ist; + } + + $offen = round($soll - $ist, 2); + + if ($ist <= 0.0) { + $status = !empty($inv['sent_at']) ? 'Gesendet' : 'Finalisiert'; + } elseif ($offen > 0.0) { + $status = 'Teilzahlung'; + } elseif ($offen < 0.0) { + $status = 'Überzahlt'; + } else { + $status = 'Bezahlt'; + } + + if ($status !== $inv['status']) { + Database::execute( + 'UPDATE invoices SET status=?, updated_at=NOW() WHERE id=?', + [$status, $invoiceId] + ); + } + + return $status; + } + + /** Wie recalcPaymentStatus(), ausgehend vom Beleg (Aufruf nach jeder Buchungsänderung). */ + public static function recalcPaymentStatusForBeleg(int $belegId): void + { + $inv = Database::fetchOne('SELECT id FROM invoices WHERE beleg_id = ? LIMIT 1', [$belegId]); + if ($inv) { + self::recalcPaymentStatus((int)$inv['id']); + } + } + public static function createBookingsFromLines( int $invoiceId, int $kategorieKontoId, @@ -220,7 +279,7 @@ class Invoice if (!$original) { throw new \RuntimeException('Originalrechnung nicht gefunden.'); } - if (!in_array($original['status'], ['Finalisiert', 'Gesendet', 'Bezahlt'])) { + if (!in_array($original['status'], self::ZAHL_STATUS, true)) { throw new \RuntimeException('Nur finalisierte, gesendete oder bezahlte Rechnungen können storniert werden.'); } if (!empty($original['storno_of_id'])) { diff --git a/src/Views/customers/show.php b/src/Views/customers/show.php index be472e1..097010a 100644 --- a/src/Views/customers/show.php +++ b/src/Views/customers/show.php @@ -52,10 +52,12 @@ 'badge-green', - 'Storniert' => 'badge-red', - 'Gesendet' => 'badge-yellow', - default => 'badge-gray', + 'Bezahlt' => 'badge-green', + 'Storniert' => 'badge-red', + 'Gesendet' => 'badge-yellow', + 'Teilzahlung' => 'badge-orange', + 'Überzahlt' => 'badge-purple', + default => 'badge-gray', }; ?> diff --git a/src/Views/invoices/book.php b/src/Views/invoices/book.php index 6dca500..f2a3942 100644 --- a/src/Views/invoices/book.php +++ b/src/Views/invoices/book.php @@ -35,14 +35,28 @@ foreach ($accounts as $acc) { Betrag
+ +
+ Gezahlt
+ +
+
+
+ +
Status
'badge-green', - 'Storniert' => 'badge-red', - 'Gesendet' => 'badge-yellow', - default => 'badge-gray', + 'Bezahlt' => 'badge-green', + 'Storniert' => 'badge-red', + 'Gesendet' => 'badge-yellow', + 'Teilzahlung' => 'badge-orange', + 'Überzahlt' => 'badge-purple', + default => 'badge-gray', }; ?> @@ -206,10 +220,13 @@ foreach ($accounts as $acc) { - +
-

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

+

+ Der Status ergibt sich normalerweise automatisch aus den erfassten Zahlungen. + Bei Restnachlass (z. B. Skonto) kann die Rechnung hier manuell als bezahlt markiert werden. +

diff --git a/src/Views/invoices/list.php b/src/Views/invoices/list.php index cf2ecae..734ac64 100644 --- a/src/Views/invoices/list.php +++ b/src/Views/invoices/list.php @@ -7,7 +7,7 @@ value="" style="width:240px"> @@ -43,10 +43,12 @@ 'badge-green', - 'Storniert' => 'badge-red', - 'Gesendet' => 'badge-yellow', - default => 'badge-gray', + 'Bezahlt' => 'badge-green', + 'Storniert' => 'badge-red', + 'Gesendet' => 'badge-yellow', + 'Teilzahlung' => 'badge-orange', + 'Überzahlt' => 'badge-purple', + default => 'badge-gray', }; $customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['vorname'] ?? '') . ' ' . $inv['nachname']); ?> diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index eab2c43..b974c62 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -19,6 +19,8 @@ $badgeClass = match($invoice['status']) { 'Storniert' => 'badge-red', 'Gesendet' => 'badge-yellow', 'Finalisiert' => 'badge-blue', + 'Teilzahlung' => 'badge-orange', + 'Überzahlt' => 'badge-purple', default => 'badge-gray', }; ?> @@ -474,10 +476,42 @@ $badgeClass = match($invoice['status']) { Noch keine Buchungen. + + + + Rechnungsbetrag + + + + + Gezahlt + + + + + + + + +
+ 0 + ? round($offen / (1 + $ustQuote), 2) + : abs((float)$invoice['betrag_netto']); + $ustVorgabe = (string)(int)round($ustQuote * 100); + if (!in_array($ustVorgabe, ['0', '7', '19'], true)) $ustVorgabe = '19'; + ?>
Neue Buchung erfassen
@@ -490,7 +524,7 @@ $badgeClass = match($invoice['status']) {
@@ -519,7 +553,7 @@ $badgeClass = match($invoice['status']) {
diff --git a/tests/73_InvoiceZahlungsstatusTest.php b/tests/73_InvoiceZahlungsstatusTest.php new file mode 100644 index 0000000..ee0972e --- /dev/null +++ b/tests/73_InvoiceZahlungsstatusTest.php @@ -0,0 +1,121 @@ +findAccountId('1200'); // Bank + $kategorieId = $this->findAccountId('8400'); // Erlöse 19 % USt + + if (!$zahlungsId || !$kategorieId) { + $this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + + $customerId = \Steuerkram\Models\Customer::create([ + 'nachname' => 'Zahlstatus', + 'firma' => 'Zahlstatus GmbH', + 'strasse' => 'Teststraße 7', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 14, + ]); + + // Rechnung über 1000 € netto / 1190 € brutto + $invoiceId = \Steuerkram\Models\Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung Zahlungsstatus', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [[ + 'bezeichnung' => 'Beratungsleistung', + 'menge' => 5, + 'einheit' => 'Std.', + 'einzelpreis' => 200.00, + 'ust_satz' => 19.0, + ]], + ]); + + $nr = \Steuerkram\Models\Invoice::finalize($invoiceId); + $invoice = \Steuerkram\Models\Invoice::find($invoiceId); + $this->assertEquals(1190.00, (float)$invoice['betrag_brutto'], 'Rechnungsbetrag 1190 € brutto'); + + // Beleg anlegen und mit der Rechnung verknüpfen + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => \Steuerkram\Models\Beleg::generateBelegNr(), + 'filename' => $nr . '.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $nr), + 'beleg_datum' => $invoice['rechnungs_datum'], + 'rechnungs_nr' => $nr, + 'betrag_brutto' => $invoice['betrag_brutto'], + 'belegtyp' => 'Ausgangsrechnung', + ]); + \Steuerkram\Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $invoiceId]); + + $buche = function (float $netto) use ($belegId, $kategorieId, $zahlungsId, $nr): int { + return \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $kategorieId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => $netto, + 'ust_satz' => 19.0, + 'buchungstext' => 'Zahlung Ausgangsrechnung ' . $nr, + ]); + }; + + $status = fn(): string => (string)\Steuerkram\Database::fetchOne( + 'SELECT status FROM invoices WHERE id=?', [$invoiceId] + )['status']; + + // 1) Teilzahlung: 400 € netto = 476 € brutto von 1190 € + $teilId = $buche(400.00); + $this->assertEquals('Teilzahlung', $status(), 'Nach Teilbetrag ist der Status Teilzahlung'); + + // 2) Restzahlung: 600 € netto = 714 € brutto → Summe exakt 1190 € + $restId = $buche(600.00); + $this->assertEquals('Bezahlt', $status(), 'Nach Ausgleich des Restbetrags ist der Status Bezahlt'); + + // 3) Überzahlung: weitere 100 € netto + $ueberId = $buche(100.00); + $this->assertEquals('Überzahlt', $status(), 'Nach zusätzlicher Zahlung ist der Status Überzahlt'); + + // 4) Überzahlung stornieren → wieder Bezahlt + \Steuerkram\Models\Booking::reverse($ueberId, 'Testkorrektur'); + $this->assertEquals('Bezahlt', $status(), 'Nach Storno der Überzahlung wieder Bezahlt'); + + // 5) Alle übrigen Buchungen stornieren → zurück auf Finalisiert (nie versendet) + \Steuerkram\Models\Booking::reverse($teilId, 'Testkorrektur'); + \Steuerkram\Models\Booking::reverse($restId, 'Testkorrektur'); + $this->assertEquals('Finalisiert', $status(), 'Ohne aktive Buchungen zurück auf Finalisiert'); + + // 6) Nach Versand fällt der Status stattdessen auf Gesendet zurück + \Steuerkram\Database::execute( + "UPDATE invoices SET status='Gesendet', sent_at=NOW() WHERE id=?", [$invoiceId] + ); + $letzteId = $buche(1000.00); + $this->assertEquals('Bezahlt', $status(), 'Vollzahlung nach Versand ergibt Bezahlt'); + \Steuerkram\Models\Booking::reverse($letzteId, 'Testkorrektur'); + $this->assertEquals('Gesendet', $status(), 'Nach Storno mit sent_at zurück auf Gesendet'); + + // Summenhilfsfunktion prüft nur aktive Einnahme-Hauptbuchungen + $this->assertEquals( + 0.0, + \Steuerkram\Models\Booking::sumBruttoForBeleg($belegId, 'Einnahme'), + 'Zahlungssumme nach allen Stornos wieder 0 €' + ); + } +}