Test: Stornorechnung End-to-End-Verifikation für UStVA und EÜR

This commit is contained in:
fds2610-bot 2026-05-05 13:54:07 +02:00
parent ccaee6190b
commit 99cf3dd25b

View file

@ -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['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']]
// ── 5. Stornobuchung muss am Originalbeleg hängen ─────────────
$reversalRows = \Steuerkram\Database::fetchAll(
'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1',
[$belegId]
);
$this->assertGreaterThan(0, count($bookings), 'Stornobuchungen wurden angelegt');
}
$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);