diff --git a/Changes.md b/Changes.md index fe9ffe4..1b0a900 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,13 @@ --- +## 2026-05-14 – Feature: UStVA Kz 81/89 auf volle Euro abgerundet + +- `UstvaCalculator`: Kz 81 und 89 werden mit `floor()` auf volle Euro abgerundet; USt wird auf dem gerundeten Wert berechnet +- Darstellung: Kz 81/89 zeigen "1.234,- €" statt "1.234,00 €" (in Tabelle und Zusammenfassung) +- USt-Felder (ust81, ust89) bleiben als Dezimalzahl unverändert +- Test 31: prüft floor-Verhalten für Kz 81 und 89 + ## 2026-05-14 – Refactor: UStVA auf 5-Kennzahlen-Schema vereinfacht - Migration 048: Kontenrahmen bereinigt – nur noch `ustva_kz` ∈ {81, 83, 61, 66} aktiv; 1571/1576 → 66, 1574 → 61, 1771/1774 → 83, alle anderen auf NULL diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index 4e3e4c4..a14acea 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -47,8 +47,8 @@ Diese Berechnung wird ausschließlich für **monatliche** Voranmeldungen unterst | Kz | Bezeichnung | Quelle | Berechnung | |---|---|---|---| -| **81** | Steuerpflichtige Umsätze zum Steuersatz von 19 % | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='81'`, EU-Steuerarten ausgeschlossen | -| **89** | Steuerpfl. innergemeinschaftliche Erwerbe 19 % | Steuerart | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_19'` | +| **81** | Steuerpflichtige Umsätze zum Steuersatz von 19 % | Konto | `floor(SUM(betrag_netto))` WHERE `ustva_kz='81'`, EU-Steuerarten ausgeschlossen – **auf volle Euro abgerundet (floor)** | +| **89** | Steuerpfl. innergemeinschaftliche Erwerbe 19 % | Steuerart | `floor(SUM(betrag_netto))` WHERE `steuerart='ig_erwerb_19'` – **auf volle Euro abgerundet (floor)** | | **66** | Gesamte Vorsteuerbeträge | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='66'` (Konten 1571, 1576) | | **61** | Vorsteuerbeträge i.g. Erwerb | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='61'` (Konto 1574) | | **83** | Vorauszahlung / Überschuss | abgeleitet | `(Summe ustva_kz='83') − Kz 66 − Kz 61` | @@ -57,8 +57,8 @@ Zusätzlich werden zwei **reine Anzeigefelder** berechnet (erscheinen im Bericht | Feld | Bezeichnung | Berechnung | |---|---|---| -| ust81 | Abzuführende USt aus Kz 81 (19 %) | `Kz 81 × 0,19` | -| ust89 | Abzuführende USt aus Kz 89 (19 %) | `Kz 89 × 0,19` | +| ust81 | Abzuführende USt aus Kz 81 (19 %) | `round(Kz 81 × 0,19, 2)` (Kz 81 ist bereits abgerundet) | +| ust89 | Abzuführende USt aus Kz 89 (19 %) | `round(Kz 89 × 0,19, 2)` (Kz 89 ist bereits abgerundet) | ### Datenquellen diff --git a/src/Services/UstvaCalculator.php b/src/Services/UstvaCalculator.php index 80e0adf..d147df6 100644 --- a/src/Services/UstvaCalculator.php +++ b/src/Services/UstvaCalculator.php @@ -75,7 +75,9 @@ class UstvaCalculator // ── 3. Kennzahlen zusammensetzen ───────────────────────────────────── $n = fn(string $kz): float => (float)($byKz[$kz] ?? 0.0); - $kz81 = $n('81'); + // Kz 81 und 89 werden laut UStVA auf volle Euro abgerundet + $kz81 = floor($n('81')); + $kz89 = floor($kz89); $kz66 = $n('66'); $kz61 = $n('61'); $kz83 = $n('83') - $kz66 - $kz61; @@ -83,13 +85,13 @@ class UstvaCalculator $ust89 = round($kz89 * 0.19, 2); return [ - '81' => ['name' => 'Steuerpflichtige Umsätze zum Steuersatz von 19 %', 'betrag' => round($kz81, 2), 'steuer' => null, 'xml' => true], - 'ust81' => ['name' => 'Abzuführende USt aus Kz 81 (19 %)', 'betrag' => null, 'steuer' => $ust81, 'xml' => false], - '89' => ['name' => 'Steuerpfl. innergemeinschaftliche Erwerbe 19 %', 'betrag' => round($kz89, 2), 'steuer' => null, 'xml' => true], - 'ust89' => ['name' => 'Abzuführende USt aus Kz 89 (19 %)', 'betrag' => null, 'steuer' => $ust89, 'xml' => false], - '66' => ['name' => 'Gesamte Vorsteuerbeträge', 'betrag' => null, 'steuer' => round($kz66, 2), 'xml' => true], - '61' => ['name' => 'Vorsteuerbeträge i.g. Erwerb', 'betrag' => null, 'steuer' => round($kz61, 2), 'xml' => true], - '83' => ['name' => 'Vorauszahlung / Überschuss', 'betrag' => null, 'steuer' => round($kz83, 2), 'xml' => true], + '81' => ['name' => 'Steuerpflichtige Umsätze zum Steuersatz von 19 %', 'betrag' => $kz81, 'steuer' => null, 'xml' => true, 'ganze_euro' => true], + 'ust81' => ['name' => 'Abzuführende USt aus Kz 81 (19 %)', 'betrag' => null, 'steuer' => $ust81, 'xml' => false, 'ganze_euro' => false], + '89' => ['name' => 'Steuerpfl. innergemeinschaftliche Erwerbe 19 %', 'betrag' => $kz89, 'steuer' => null, 'xml' => true, 'ganze_euro' => true], + 'ust89' => ['name' => 'Abzuführende USt aus Kz 89 (19 %)', 'betrag' => null, 'steuer' => $ust89, 'xml' => false, 'ganze_euro' => false], + '66' => ['name' => 'Gesamte Vorsteuerbeträge', 'betrag' => null, 'steuer' => round($kz66, 2), 'xml' => true, 'ganze_euro' => false], + '61' => ['name' => 'Vorsteuerbeträge i.g. Erwerb', 'betrag' => null, 'steuer' => round($kz61, 2), 'xml' => true, 'ganze_euro' => false], + '83' => ['name' => 'Vorauszahlung / Überschuss', 'betrag' => null, 'steuer' => round($kz83, 2), 'xml' => true, 'ganze_euro' => false], ]; } diff --git a/src/Views/tax/ustva_show.php b/src/Views/tax/ustva_show.php index 5bbb223..ed8c425 100644 --- a/src/Views/tax/ustva_show.php +++ b/src/Views/tax/ustva_show.php @@ -2,7 +2,8 @@ use Steuerkram\View; $h = fn($v) => View::h($v); $fmt = fn($v) => View::formatMoney($v); -$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–'; +$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–'; +$fmtKzGanz = fn($v) => $v !== null ? number_format((float)$v, 0, ',', '.') . ',- €' : '–'; ?>
@@ -52,11 +53,13 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' - + - + - + @@ -117,12 +120,12 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' - + - + diff --git a/tests/31_UstvaFloorKz81_89Test.php b/tests/31_UstvaFloorKz81_89Test.php new file mode 100644 index 0000000..e432c74 --- /dev/null +++ b/tests/31_UstvaFloorKz81_89Test.php @@ -0,0 +1,118 @@ +findAccountByNr('8400'); // Erlöse 19 % USt → ustva_kz=81 + $ausgabeId = $this->findKonto('Betriebsausgabe'); + $zahlungId = $this->findKonto('Zahlungskonto'); + + if (!$erloesId || !$ausgabeId || !$zahlungId) { + $this->assert(false, 'Benötigte Konten nicht gefunden'); + return; + } + + $calc = new \Steuerkram\Services\UstvaCalculator(); + $kzPre = $calc->calculate($year, $month); + + // Kz-81-Buchung mit Cent-Betrag: 100,77 € netto + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'TEST-FLOOR81-' . uniqid(), + 'filename' => 'test-floor81.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-floor81-' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Ausgangsrechnung', + ]); + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Einnahme', + 'steuerart' => null, + 'ust_satz' => 19.0, + 'kategorie_konto_id' => $erloesId, + 'zahlungskonto_id' => $zahlungId, + 'betrag_netto' => 100.77, + 'buchungstext' => 'Test Floor Kz 81', + ]); + + // Kz-89-Buchung mit Cent-Betrag: 200,55 € netto (ig. Erwerb 19 %) + $belegId2 = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'TEST-FLOOR89-' . uniqid(), + 'filename' => 'test-floor89.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-floor89-' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', + ]); + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId2, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Ausgabe', + 'steuerart' => 'ig_erwerb_19', + 'ust_satz' => 0.0, + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $zahlungId, + 'betrag_netto' => 200.55, + 'buchungstext' => 'Test Floor Kz 89', + ]); + + $kz = $calc->calculate($year, $month); + + // Kz 81: Delta muss exakt 100,00 sein (floor von 100,77) + $delta81 = round((float)$kz['81']['betrag'] - (float)$kzPre['81']['betrag'], 2); + $this->assertEquals(100.00, $delta81, 'Kz 81: floor(100,77) = 100,00'); + + // USt Kz 81: 100 * 0,19 = 19,00 (nicht 100,77 * 0,19 = 19,15) + $deltaUst81 = round((float)$kz['ust81']['steuer'] - (float)$kzPre['ust81']['steuer'], 2); + $this->assertEquals(19.00, $deltaUst81, 'USt Kz 81: 100 × 0,19 = 19,00'); + + // Kz 89: Delta muss exakt 200,00 sein (floor von 200,55) + $delta89 = round((float)$kz['89']['betrag'] - (float)$kzPre['89']['betrag'], 2); + $this->assertEquals(200.00, $delta89, 'Kz 89: floor(200,55) = 200,00'); + + // USt Kz 89: 200 * 0,19 = 38,00 (nicht 200,55 * 0,19 = 38,10) + $deltaUst89 = round((float)$kz['ust89']['steuer'] - (float)$kzPre['ust89']['steuer'], 2); + $this->assertEquals(38.00, $deltaUst89, 'USt Kz 89: 200 × 0,19 = 38,00'); + + // ganze_euro-Flag muss gesetzt sein + $this->assert($kz['81']['ganze_euro'] === true, 'Kz 81 hat ganze_euro=true'); + $this->assert($kz['89']['ganze_euro'] === true, 'Kz 89 hat ganze_euro=true'); + $this->assert($kz['ust81']['ganze_euro'] === false, 'ust81 hat ganze_euro=false'); + } + + private function findKonto(string $kontenart): ?int + { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = ? AND aktiv = 1 LIMIT 1", + [$kontenart] + ); + return $row ? (int)$row['id'] : null; + } + + private function findAccountByNr(string $nr): ?int + { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = ? AND aktiv = 1 LIMIT 1", + [$nr] + ); + return $row ? (int)$row['id'] : null; + } +}
Umsatz 19 % (Kz 81)
ig. Erwerb 19 % (Kz 89)