Feature: UStVA Kz 81/89 auf volle Euro abgerundet, Darstellung mit ,-

This commit is contained in:
fds2610-bot 2026-05-14 18:58:12 +02:00
parent 88aecd86c2
commit 3235030305
5 changed files with 148 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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],
];
}

View file

@ -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, ',', '.') . ',- €' : '–';
?>
<div style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start">
@ -52,11 +53,13 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
<td class="td-mono" style="color:var(--color-muted);width:50px"><?= $isDisplay ? '' : $h($nr) ?></td>
<td><?= $h($field['name']) ?></td>
<td class="td-right td-mono">
<?php if ($field['betrag'] !== null): ?>
<?php if ($field['betrag'] !== null):
$fmtB = ($field['ganze_euro'] ?? false) ? $fmtKzGanz : $fmtKz;
?>
<?php if (!$isDisplay): ?>
<a href="/tax/ustva/<?= $year ?>/<?= $month ?>/kz/<?= $nr ?>" style="text-decoration:none;color:inherit"><?= $fmtKz($field['betrag']) ?></a>
<a href="/tax/ustva/<?= $year ?>/<?= $month ?>/kz/<?= $nr ?>" style="text-decoration:none;color:inherit"><?= $fmtB($field['betrag']) ?></a>
<?php else: ?>
<?= $fmtKz($field['betrag']) ?>
<?= $fmtB($field['betrag']) ?>
<?php endif; ?>
<?php else: ?>
<?= $isResult ? '' : '–' ?>
@ -117,12 +120,12 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
<table style="width:100%;font-size:.9rem">
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 19 % (Kz 81)</td>
<td class="td-right td-mono"><?= $fmtKz($kz81val) ?></td>
<td class="td-right td-mono"><?= $fmtKzGanz($kz81val) ?></td>
</tr>
<?php if ($kz89val != 0.0): ?>
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">ig. Erwerb 19 % (Kz 89)</td>
<td class="td-right td-mono"><?= $fmtKz($kz89val) ?></td>
<td class="td-right td-mono"><?= $fmtKzGanz($kz89val) ?></td>
</tr>
<?php endif; ?>
<tr>

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* Test 31: Kz 81 und Kz 89 werden auf volle Euro abgerundet (floor).
*
* Buchungen mit Cent-Beträgen dürfen in der UStVA nur als ganzer Euro-Betrag
* erscheinen; die USt wird auf dem abgerundeten Wert berechnet.
*/
class UstvaFloorKz81_89Test extends TestCase
{
protected string $testName = 'Test 31: UStVA Kz 81/89 – floor auf volle Euro';
public function run(): void
{
$year = (int)date('Y');
$month = (int)date('n');
$datum = date('Y-m-d');
$erloesId = $this->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;
}
}