220 lines
10 KiB
PHP
220 lines
10 KiB
PHP
<?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');
|
||
}
|
||
}
|