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', 'vorname' => 'Test', 'firma' => 'Storno GmbH', 'strasse' => 'Testweg 1', 'plz' => '10115', 'ort' => 'Berlin', 'land' => 'DE', 'payment_days' => 14, ]); // ── 1. Originalrechnung erstellen + finalisieren ────────────── $originalId = \Steuerkram\Models\Invoice::create([ 'customer_id' => $customerId, 'betreff' => 'Testrechnung für Storno', 'rechnungs_datum' => $datum, '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'); // ── 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] ); $bookingIds = \Steuerkram\Models\Invoice::createBookingsFromLines( $originalId, $kategorieId, $zahlungsId, $datum ); $this->assertGreaterThan(0, count($bookingIds), 'createBookingsFromLines erzeugt Buchungen'); $origBookingId = $bookingIds[0]; $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'); $storno = \Steuerkram\Models\Invoice::find($stornoId); $this->assertEquals($originalId, (int)$storno['storno_of_id'], 'storno_of_id zeigt auf Original'); $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 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['betrag_netto'] < 0, 'Position Netto < 0'); $this->assert((float)$line['betrag_ust'] < 0, 'Position USt < 0'); } } // ── 5. Stornobuchung muss am Originalbeleg hängen ───────────── $reversalRows = \Steuerkram\Database::fetchAll( 'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1 AND parent_booking_id IS NULL', [$belegId] ); $this->assertEquals(1, count($reversalRows), 'Genau eine Hauptstornobuchung 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'); // 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); } catch (\RuntimeException $e) { $exception = true; } $this->assert($exception, 'Stornorechnung kann nicht erneut storniert werden'); } }