Steuerkram/tests/27_StornorechnungTest.php
fds2610-bot 55c02b54de Feature: USt-Buchungszeilen für Ausgangsrechnungen einführen
- Migration 046: ustva_kz='83'/'84' für Konten 1776/1771, Bestandsmigration für Einnahme-Buchungen
- Booking::create(): USt-Kindzeile auf 1771/1776 für Einnahmen mit 7/19 % (analog VSt)
- UstvaCalculator: Kz 83/84 kontobasiert via 1771/1776, KZ_ACCOUNT_MAP aktualisiert
- Invoice::cancel(): nur Hauptbuchungen direkt stornieren (parent_booking_id IS NULL)
- Tests 10, 27 angepasst; Test 29 neu
2026-05-10 15:11:00 +02:00

206 lines
9.6 KiB
PHP
Raw 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]
);
$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');
}
}