Fix: UStVA Vorsteuer-Kz-Aufteilung (Migration 022) + Tests korrigiert

This commit is contained in:
fds2610-bot 2026-04-10 20:33:14 +02:00
parent aa36a16c5a
commit e1f356d0cf
8 changed files with 195 additions and 111 deletions

View file

@ -41,36 +41,29 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d
**Datei:** `src/Services/UstvaCalculator.php`
Die UStVA basiert ausschließlich auf dem Feld `accounts.ustva_kz` des Kategorie-Kontos einer Buchung. Die Felder `buchungstyp` und `steuerart` werden nicht ausgewertet. Stornobuchungen (`is_reversal = 1`) werden ausgeschlossen.
Die UStVA kombiniert zwei Quellen (Migration 022):
### Berechnungslogik
**Quelle 1 – kontobasiert** (`accounts.ustva_kz`): reguläre Umsätze und Vorsteuer. Buchungen mit EU-Steuerarten (s.u.) werden hier ausgeschlossen.
Pro Monat wird eine einzige Aggregationsabfrage ausgeführt, die `betrag_netto` und `betrag_ust` nach `ustva_kz` des Kategorie-Kontos gruppiert:
```sql
SELECT a.ustva_kz, SUM(betrag_netto), SUM(betrag_ust)
FROM bookings JOIN accounts a ON a.id = kategorie_konto_id
WHERE ... AND a.ustva_kz IS NOT NULL
GROUP BY a.ustva_kz
```
**Quelle 2 – steuerartbasiert** (`bookings.steuerart`): EU-Sonderfälle (ig. Lieferung, Ausfuhr, ig. Erwerb). Diese Buchungen landen unabhängig vom Kategorie-Konto in den richtigen Kennzahlen.
### Kontozuordnung und Kennzahlen
| Kz | Bezeichnung | Quelle | Relevante Konten (Beispiele) |
| Kz | Bezeichnung | Quelle | Erläuterung |
|---|---|---|---|
| **81** | Steuerpfl. Umsätze 19 % | `SUM(betrag_netto)` WHERE `ustva_kz='81'` | 8000, 8400, 8510, 8570, 8800, 8820, 8922 |
| **81** | Steuerpfl. Umsätze 19 % | `SUM(betrag_netto)` WHERE `ustva_kz='81'` | 8400, 8570, 8720, 8801, 8820, 8922 |
| **83** | USt auf steuerpfl. Umsätze 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='81'` | s.o. |
| **86** | Steuerpfl. Umsätze 7 % | `SUM(betrag_netto)` WHERE `ustva_kz='86'` | 8300 |
| **86** | Steuerpfl. Umsätze 7 % | `SUM(betrag_netto)` WHERE `ustva_kz='86'` | 8300, 8710, 8921 |
| **84** | USt auf steuerpfl. Umsätze 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='86'` | s.o. |
| **35** | Steuerfreie Umsätze | `SUM(betrag_netto)` WHERE `ustva_kz='35'` | 8100, 8105, 8110, 8160 |
| **41** | Steuerfreie ig. Lieferungen | `SUM(betrag_netto)` WHERE `ustva_kz='41'` | 8120, 8125, 8130 |
| **44** | Steuerfreie Ausfuhren | `SUM(betrag_netto)` WHERE `ustva_kz='44'` | 8150 |
| **61** | Vorsteuer 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='61'` | alle Betriebsausgabe-Konten mit `steuercode='VSt19'` (z.B. 3100, 4200ff, 4900ff) |
| **62** | Vorsteuer 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='62'` | alle Betriebsausgabe-Konten mit `steuercode='VSt7'` (z.B. 3110, 4610, 4680) |
| **89** | Ig. Erwerb 19 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `ustva_kz='89'` | 3425 |
| **91** | USt auf ig. Erwerb 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='89'` | 3425 |
| **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `ustva_kz='93'` | 3420 |
| **94** | USt auf ig. Erwerb 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='93'` | 3420 |
| **35** | Steuerfreie Umsätze | `SUM(betrag_netto)` WHERE `ustva_kz='35'` | 8100, 8105, 8110 |
| **41** | Steuerfreie ig. Lieferungen | kontobasiert (`ustva_kz='41'`) **+** `steuerart='ig_lieferung'` | 8125; oder Buchung mit steuerart |
| **44** | Steuerfreie Ausfuhren | kontobasiert (`ustva_kz='44'`) **+** `steuerart='export'` | 8120; oder Buchung mit steuerart |
| **61** | Vorsteuer 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='61'` | alle Betriebsausgabe-Konten mit `steuercode='VSt19'` (Migration 022: früher kz=66) |
| **62** | Vorsteuer 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='62'` | alle Betriebsausgabe-Konten mit `steuercode='VSt7'` (Migration 022: früher kz=66) |
| **89** | Ig. Erwerb 19 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_19'` | kein spezielles Konto nötig |
| **91** | USt auf ig. Erwerb 19 % | `Kz 89 × 0,19` | berechnet |
| **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_7'` | kein spezielles Konto nötig |
| **94** | USt auf ig. Erwerb 7 % | `Kz 93 × 0,07` | berechnet |
### Abgeleitete Kennzahlen
@ -80,11 +73,10 @@ GROUP BY a.ustva_kz
| **66** | Vorsteuerbeträge gesamt | Kz 61 + Kz 62 + Kz 67 |
| **69** | Verbleibende USt-Vorauszahlung / Erstattung | (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 |
### Neue Konten in Klasse 3 (Migration 010)
### Migration 022 – Vorsteuer-Kz-Aufteilung
| Konto | Bezeichnung | ustva_kz |
|---|---|---|
| 3420 | Innergemeinschaftliche Erwerbe 7 % Vorsteuer | 93 |
| 3425 | Innergemeinschaftliche Erwerbe 19 % Vorsteuer | 89 |
Vor Migration 022 trugen alle Vorsteuer-Konten `ustva_kz='66'`, was dazu führte, dass Kz 61, 62 und 66 stets 0 ergaben. Migration 022 setzt:
- `steuercode='VSt19'` → `ustva_kz='61'`
- `steuercode='VSt7'` → `ustva_kz='62'`
Buchungen auf ig. Erwerb müssen auf Konto 3420 bzw. 3425 als Kategorie-Konto erfolgen.
Kz 66 wird weiterhin rechnerisch als `Kz 61 + Kz 62 + Kz 67` aggregiert.

View file

@ -2,6 +2,16 @@
---
## 2026-04-10 – UStVA Vorsteuer-Kz-Aufteilung (Migration 022)
### Fix
- Migration 022: Vorsteuer-Konten von `ustva_kz='66'` auf `'61'` (VSt19) bzw. `'62'` (VSt7) aufgeteilt – Kz 61, 62 und 66 wurden zuvor stets als 0 ausgewiesen
- `UstvaCalculator`: zweite Abfrage für steuerart-basierte EU-Kennzahlen (ig. Lieferung → Kz 41, Ausfuhr → Kz 44, ig. Erwerb → Kz 89/91/93/94); verhindert Doppelzählung gegenüber kontobasierter Berechnung
- `fetchBookingsForKz`: UNION-Abfrage aus Konto-Kz und steuerart für korrekten Drill-down
- Tests 4, 5, 6, 9: Kontonummern auf tatsächliche SKR03-Nummern korrigiert (8000→8400, 1400→1200)
---
## 2026-04-10 – Upload-Limit auf 20 MB erhöht
### Geändert

View file

@ -0,0 +1,17 @@
-- Migration 022: Vorsteuer-Konten ustva_kz von '66' auf '61' (VSt19) bzw. '62' (VSt7) aufteilen.
--
-- Hintergrund: Der UstvaCalculator berechnet Kz 66 (Vorsteuer gesamt) als Summe
-- aus Kz 61 + Kz 62 + Kz 67. Alle Vorsteuer-Konten hatten bisher ustva_kz='66',
-- was dazu führte, dass Kz 61, 62 und damit auch 66 stets 0 ergaben.
-- Nach dieser Migration tragen Konten mit VSt19-Steuercode zu Kz 61 bei,
-- Konten mit VSt7 zu Kz 62. Kz 66 wird weiterhin rechnerisch aggregiert.
UPDATE accounts
SET ustva_kz = '61'
WHERE ustva_kz = '66'
AND steuercode = 'VSt19';
UPDATE accounts
SET ustva_kz = '62'
WHERE ustva_kz = '66'
AND steuercode = 'VSt7';

View file

@ -9,18 +9,24 @@ use Steuerkram\Database;
/**
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
*
* Maßgeblich ist ausschließlich das Feld accounts.ustva_kz des Kategorie-Kontos.
* Zwei Quellen fließen in die Berechnung ein:
*
* Kontozuordnung:
* 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos):
* ustva_kz = '81' → Kz 81 (betrag_netto), Kz 83 (betrag_ust)
* ustva_kz = '86' → Kz 86 (betrag_netto), Kz 84 (betrag_ust)
* ustva_kz = '35' → Kz 35 (betrag_netto)
* ustva_kz = '41' → Kz 41 (betrag_netto)
* ustva_kz = '44' → Kz 44 (betrag_netto)
* ustva_kz = '61' → Kz 61 (betrag_ust) [Betriebsausgabe-Konten mit VSt19]
* ustva_kz = '62' → Kz 62 (betrag_ust) [Betriebsausgabe-Konten mit VSt7]
* ustva_kz = '89' → Kz 89 (betrag_netto), Kz 91 (betrag_ust) [ig. Erwerb 19 %]
* ustva_kz = '93' → Kz 93 (betrag_netto), Kz 94 (betrag_ust) [ig. Erwerb 7 %]
* ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto]
* ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto]
* ustva_kz = '61' → Kz 61 (betrag_ust) [Vorsteuer 19 %, VSt19-Konten]
* ustva_kz = '62' → Kz 62 (betrag_ust) [Vorsteuer 7 %, VSt7-Konten]
*
* 2. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto):
* steuerart = 'ig_lieferung' → Kz 41 (betrag_netto)
* steuerart = 'export' → Kz 44 (betrag_netto)
* steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto), Kz 91 (netto × 0,19)
* steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto), Kz 94 (netto × 0,07)
* Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung
* ausgeschlossen, um Doppelzählung zu vermeiden.
*
* Abgeleitete Kennzahlen:
* Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb)
@ -29,8 +35,12 @@ use Steuerkram\Database;
*/
class UstvaCalculator
{
/** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */
private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'];
/**
* Welche accounts.ustva_kz-Werte liefern Buchungen für die Drill-down-Ansicht einer Kennzahl.
* Welche accounts.ustva_kz-Werte liefern Buchungen für die Drill-down-Ansicht.
* Nur für den kontobasierten Teil; EU-Steuerarten kommen aus KZ_STEUERART_MAP.
*/
private const KZ_ACCOUNT_MAP = [
'81' => ['81'],
@ -42,18 +52,28 @@ class UstvaCalculator
'44' => ['44'],
'61' => ['61'],
'62' => ['62'],
'89' => ['89'],
'91' => ['89'],
'93' => ['93'],
'94' => ['93'],
'67' => ['89', '93'],
'66' => ['61', '62', '89', '93'],
'69' => ['81', '86', '35', '41', '44', '61', '62', '89', '93'],
];
/**
* Welche steuerart-Werte liefern Buchungen für eine Kennzahl.
*/
private const KZ_STEUERART_MAP = [
'41' => ['ig_lieferung'],
'44' => ['export'],
'89' => ['ig_erwerb_19'],
'91' => ['ig_erwerb_19'],
'93' => ['ig_erwerb_7'],
'94' => ['ig_erwerb_7'],
'67' => ['ig_erwerb_19', 'ig_erwerb_7'],
'66' => ['ig_erwerb_19', 'ig_erwerb_7'],
'69' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'],
];
public function calculate(int $year, int $month): array
{
$rows = Database::fetchAll(
// ── 1. Kontobasierte Kennzahlen (EU-Steuerarten ausgeschlossen) ──────
$euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$accountRows = Database::fetchAll(
'SELECT a.ustva_kz,
SUM(bk.betrag_netto) AS summe_netto,
SUM(bk.betrag_ust) AS summe_ust
@ -62,28 +82,49 @@ class UstvaCalculator
JOIN accounts a ON a.id = bk.kategorie_konto_id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND a.ustva_kz IS NOT NULL AND a.ustva_kz != \'\'
AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY a.ustva_kz',
[$year, $month]
array_merge([$year, $month], self::EU_STEUERARTEN)
);
$byKz = [];
foreach ($rows as $row) {
foreach ($accountRows as $row) {
$byKz[$row['ustva_kz']] = [
'netto' => (float)$row['summe_netto'],
'ust' => (float)$row['summe_ust'],
];
}
// ── 2. Steuerart-basierte Kennzahlen ─────────────────────────────────
$stRows = Database::fetchAll(
'SELECT bk.steuerart, SUM(bk.betrag_netto) AS summe_netto
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.steuerart IN (' . $euPlaceholders . ')
GROUP BY bk.steuerart',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
$bySteuerart = [];
foreach ($stRows as $row) {
$bySteuerart[$row['steuerart']] = (float)$row['summe_netto'];
}
// ── 3. Kennzahlen zusammensetzen ─────────────────────────────────────
$n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0);
$u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0);
$st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0);
$kz81 = $n('81'); $kz83 = $u('81');
$kz86 = $n('86'); $kz84 = $u('86');
$kz35 = $n('35');
$kz41 = $n('41');
$kz44 = $n('44');
$kz89 = $n('89'); $kz91 = $u('89');
$kz93 = $n('93'); $kz94 = $u('93');
$kz41 = $n('41') + $st('ig_lieferung');
$kz44 = $n('44') + $st('export');
$kz89 = $st('ig_erwerb_19');
$kz91 = round($kz89 * 0.19, 2);
$kz93 = $st('ig_erwerb_7');
$kz94 = round($kz93 * 0.07, 2);
$kz61 = $u('61');
$kz62 = $u('62');
$kz67 = $kz91 + $kz94;
@ -110,18 +151,20 @@ class UstvaCalculator
];
}
/**
* Liefert alle Buchungszeilen, die zu einer Kennzahl beitragen (Drill-down).
* Kombiniert kontobasierte und steuerart-basierte Quellen per UNION.
*/
public function fetchBookingsForKz(int $year, int $month, string $kz): array
{
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? null;
if ($accountKzList === null) {
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
if (empty($accountKzList) && empty($steuerartList)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($accountKzList), '?'));
$params = array_merge([$year, $month], $accountKzList);
return Database::fetchAll(
'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.steuerart, bk.ust_satz,
$select = 'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.steuerart, bk.ust_satz,
bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext,
bk.is_reversal, bk.beleg_id, bl.beleg_nr,
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
@ -131,10 +174,32 @@ class UstvaCalculator
LEFT JOIN belege bl ON bk.beleg_id = bl.id
JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND k.ustva_kz IN (' . $placeholders . ')
ORDER BY bk.buchungs_datum, bk.id',
$params
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?';
$parts = [];
$params = [];
if (!empty($accountKzList)) {
$ph = implode(',', array_fill(0, count($accountKzList), '?'));
$euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$parts[] = $select
. ' AND k.ustva_kz IN (' . $ph . ')'
. ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))';
$params = array_merge($params,
[$year, $month],
$accountKzList,
self::EU_STEUERARTEN
);
}
if (!empty($steuerartList)) {
$ph = implode(',', array_fill(0, count($steuerartList), '?'));
$parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')';
$params = array_merge($params, [$year, $month], $steuerartList);
}
$sql = implode(' UNION ALL ', $parts) . ' ORDER BY buchungs_datum, id';
return Database::fetchAll($sql, $params);
}
}

View file

@ -16,19 +16,19 @@ class InvoiceBuchungTest extends TestCase
public function run(): void
{
// Benötigte Konten prüfen (SKR03)
$zahlungsId = $this->findAccountId('1400'); // Forderungen (Zahlungskonto)
$kategorieId = $this->findAccountId('8000'); // Erlöse 19 % (Betriebseinnahme)
$zahlungsId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
$kategorieId = $this->findAccountId('8400'); // Erlöse 19 % USt (Betriebseinnahme)
if (!$zahlungsId) {
$this->assert(false, 'Forderungskonto 1400 nicht gefunden – SKR03-Seed vorhanden?');
$this->assert(false, 'Bankkonto 1200 nicht gefunden – SKR03-Seed vorhanden?');
return;
}
if (!$kategorieId) {
$this->assert(false, 'Erlöskonto 8000 nicht gefunden – SKR03-Seed vorhanden?');
$this->assert(false, 'Erlöskonto 8400 nicht gefunden – SKR03-Seed vorhanden?');
return;
}
$this->assert(true, 'Benötigte Konten (1400, 8000) vorhanden');
$this->assert(true, 'Benötigte Konten (1200, 8400) vorhanden');
// Testkunden anlegen
$customerId = \Steuerkram\Models\Customer::create([

View file

@ -19,15 +19,15 @@ class TaxSummenTest extends TestCase
$datum = date('Y-m-d');
// Benötigte Konten (SKR03)
$zahlungsId = $this->findAccountId('1400'); // Forderungen (Zahlungskonto)
$erloesId = $this->findAccountId('8000'); // Erlöse 19 % (Betriebseinnahme)
$zahlungsId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
$erloesId = $this->findAccountId('8400'); // Erlöse 19 % USt (Betriebseinnahme, kz=81)
$ausgabeId = $this->findAccountId('4920'); // Bürobedarf (Betriebsausgabe)
$bankId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
// Fallback auf beliebige Konten der passenden Art
// Fallback: explizit ein Konto mit ustva_kz=81 suchen, damit kz81/83 befüllt werden
if (!$erloesId) {
$row = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Betriebseinnahme' AND aktiv = 1 LIMIT 1"
"SELECT id FROM accounts WHERE kontenart = 'Betriebseinnahme' AND ustva_kz = '81' AND aktiv = 1 LIMIT 1"
);
$erloesId = $row ? (int)$row['id'] : null;
}

View file

@ -42,12 +42,12 @@ class EuerBuchungsTest extends TestCase
);
// Konten laden
$erloesId = $this->findAccountId('8000'); // Betriebseinnahme
$ausgabeId = $this->findAccountId('4600'); // Bürobedarf (Betriebsausgabe)
$erloesId = $this->findAccountId('8400'); // Erlöse 19 % USt (Betriebseinnahme)
$ausgabeId = $this->findAccountId('4600'); // Werbekosten (Betriebsausgabe)
$bankId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
if (!$erloesId || !$ausgabeId || !$bankId) {
$this->assert(false, 'Benötigte Konten (8000, 4600, 1200) nicht gefunden');
$this->assert(false, 'Benötigte Konten (8400, 4600, 1200) nicht gefunden');
return;
}

View file

@ -20,14 +20,14 @@ class InvoiceToReportsTest extends TestCase
$datum = date('Y-m-d');
// ── Konten prüfen ────────────────────────────────────────────────────
$zahlungsId = $this->findAccountId('1400'); // Forderungen
$erloes19Id = $this->findAccountId('8000'); // Erlöse 19 %
$zahlungsId = $this->findAccountId('1200'); // Bank
$erloes19Id = $this->findAccountId('8400'); // Erlöse 19 % USt
if (!$zahlungsId || !$erloes19Id) {
$this->assert(false, 'Benötigte Konten (1400, 8000) nicht gefunden – SKR03-Seed vorhanden?');
$this->assert(false, 'Benötigte Konten (1200, 8400) nicht gefunden – SKR03-Seed vorhanden?');
return;
}
$this->assert(true, 'Konten 1400 und 8000 vorhanden');
$this->assert(true, 'Konten 1200 und 8400 vorhanden');
// ── Kunde und Rechnung anlegen ────────────────────────────────────────
$customerId = \Steuerkram\Models\Customer::create([