From 99cf3dd25bc320770e63da094c34e5bc4b29bbba Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 5 May 2026 13:54:07 +0200 Subject: [PATCH] =?UTF-8?q?Test:=20Stornorechnung=20End-to-End-Verifikatio?= =?UTF-8?q?n=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);