Steuerkram/tests/27_StornorechnungTest.php

220 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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 – 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',
'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]
);
// ── 3. Baseline VOR der Originalbuchung erfassen ──────────────
$ustvaCalc = new \Steuerkram\Services\UstvaCalculator();
$kzBaseline = $ustvaCalc->calculate($year, $month);
$kz81NettoBaseline = (float)($kzBaseline['81']['betrag'] ?? 0);
$kz83UstBaseline = (float)($kzBaseline['83']['steuer'] ?? 0);
$euerCalc = new \Steuerkram\Services\EuerCalculator();
$euerBaseline = $euerCalc->calculate($year);
$kontoNettoBaseline = 0.0;
foreach ($euerBaseline['einnahmen'] as $row) {
if ((int)$row['id'] === (int)$kategorieId) {
$kontoNettoBaseline = (float)$row['netto'];
break;
}
}
$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');
// ── 4a. UStVA + EÜR Stand VOR Stornorechnung erfassen ─────────
$kzPre = $ustvaCalc->calculate($year, $month);
$kz81NettoPre = (float)($kzPre['81']['betrag'] ?? 0);
$kz83UstPre = (float)($kzPre['83']['steuer'] ?? 0);
$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 hat die Kennzahlen um erwarteten Betrag erhöht
$this->assertEquals(250.0, round($kz81NettoPre - $kz81NettoBaseline, 2), 'UStVA Kz 81: Originalbuchung +250 Netto');
$this->assertEquals(47.50, round($kz83UstPre - $kz83UstBaseline, 2), 'UStVA Kz 83: Originalbuchung +47,50 USt');
$this->assertEquals(250.0, round($kontoNettoPre - $kontoNettoBaseline, 2), 'EÜR: Erlös-Konto +250 durch Originalbuchung');
// ── 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');
}
}