Feature: Vorsteuerkonten 1571/1576 aktivieren, VSt-Buchungszeilen einführen

- Migration 044: Konten 1574 (VSt i.g.E. 19%) und 1774 (USt i.g.E. 19%) anlegen
- Migration 045: Konten 1571/1576 anlegen, ustva_kz='61'/'62' setzen,
  altes DATEV-Routing (ustva_kz '61'/'62' auf Betriebsausgabe-Konten) entfernen,
  parent_booking_id-Spalte, VSt-Buchungszeilen für Bestandsbuchungen migrieren
- Booking::create(): automatisch VSt-Zeile auf 1576/1571 für Ausgaben mit 7/19 %
- Booking::reverse(): VSt-Zeilen beim Storno mitaufheben (reversed_by setzen)
- BookingController::destroy(): VSt-Kindzeilen beim Löschen (Testmodus) mit löschen
- UstvaCalculator: Kz 61/62 jetzt kontobasiert via 1576/1571 (betrag_netto),
  Step 2 (buchungsbasiert via ust_satz) entfernt
- Beleg::all/find: betrag_brutto-Aggregation und Suche schließen VSt-Zeilen aus
- Tests 12, 18 angepasst; Test 28 neu
This commit is contained in:
fds2610-bot 2026-05-10 14:45:16 +02:00
parent 8a9fa120f2
commit d3a6c379c9
10 changed files with 354 additions and 76 deletions

View file

@ -0,0 +1,12 @@
-- Migration 044: Konten für ig. Erwerb (ReverseCharge) anlegen
--
-- 1574: Abziehbare Vorsteuer aus ig. Erwerb 19 % (nach Reset in 013 nicht mehr vorhanden)
-- 1774: Umsatzsteuer aus ig. Erwerb 19 % (neu)
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, is_system)
VALUES ('1574', 'Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %', 'Zahlungskonto', 'VSt19', NULL, 1)
ON DUPLICATE KEY UPDATE aktiv = 1;
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, is_system)
VALUES ('1774', 'Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %', 'Verbindlichkeit', 'USt19', NULL, 1)
ON DUPLICATE KEY UPDATE aktiv = 1;

View file

@ -0,0 +1,78 @@
-- Migration 045: Vorsteuerkonten aktivieren und VSt-Buchungszeilen einführen
--
-- Hintergrund: Bisher wurden Vorsteuerbeträge direkt in betrag_ust der Ausgabe-Buchung
-- geführt. Ab jetzt erhält jede Ausgabe-Buchung mit ust_satz 7/19 (steuerart=normal)
-- eine eigene VSt-Buchungszeile auf Konto 1571 (7%) bzw. 1576 (19%).
--
-- Änderungen:
-- 1. Konten 1571 und 1576 anlegen (nach Reset in Migration 013 nicht mehr vorhanden)
-- 2. ustva_kz: 1576→'61', 1571→'62' (bisher '66' Sammelkennzahl)
-- 3. ustva_kz von Betriebsausgabe-Konten mit '61'/'62' leeren (DATEV-Altmapping)
-- 4. Neue Spalte parent_booking_id in bookings (VSt-Zeile → Hauptbuchung)
-- 5. VSt-Buchungszeilen für alle bestehenden qualifizierten Ausgabe-Buchungen
-- ── 1. Konten anlegen ────────────────────────────────────────────────────────
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, euer_zeile, is_system, aktiv) VALUES
('1571', 'Abziehbare Vorsteuer 7 %', 'Zahlungskonto', 'VSt7', '62', NULL, 1, 1),
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Zahlungskonto', 'VSt19', '61', NULL, 1, 1)
ON DUPLICATE KEY UPDATE ustva_kz = VALUES(ustva_kz), aktiv = 1;
-- ── 3. Altes DATEV-Routing von Betriebsausgabe-Konten entfernen ─────────────────
-- Diese Konten hatten historisch ustva_kz='61'/'62' für direkte Kz-Zuordnung.
-- Kz 61/62 werden ab jetzt ausschließlich über VSt-Buchungszeilen auf 1571/1576 befüllt.
UPDATE accounts
SET ustva_kz = NULL
WHERE kontenart = 'Betriebsausgabe' AND ustva_kz IN ('61', '62');
-- ── 2. ustva_kz für 1574 auf '67' setzen (Vorsteuer ig. Erwerb, für späteres RC) ─
UPDATE accounts SET ustva_kz = '67' WHERE kontonummer = '1574' AND (ustva_kz IS NULL OR ustva_kz = '');
-- ── 3. Spalte parent_booking_id ──────────────────────────────────────────────
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS parent_booking_id INT UNSIGNED NULL COMMENT 'ID der Hauptbuchung (für VSt-Buchungszeilen)' AFTER beleg_id;
-- ── 4. VSt-Buchungszeilen für Bestandsbuchungen ──────────────────────────────
-- Kriterien: buchungstyp=Ausgabe, ust_satz IN (7,19), steuerart=normal, betrag_ust!=0
-- Idempotenz: WHERE NOT EXISTS verhindert Doppel-Einfügung
INSERT INTO bookings (
buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, is_reversal, fiscal_year_id
)
SELECT
CONCAT('VST-M-', LPAD(b.id, 8, '0')),
b.beleg_id,
b.id,
b.buchungs_datum,
'Ausgabe',
a_vst.id,
b.zahlungskonto_id,
b.betrag_ust,
0.00,
'normal',
0.00,
b.betrag_ust,
CONCAT('Vorsteuer zu ', b.buchungs_nr),
b.is_reversal,
b.fiscal_year_id
FROM bookings b
JOIN accounts a_vst ON (
(ROUND(b.ust_satz) = 19 AND a_vst.kontonummer = '1576')
OR
(ROUND(b.ust_satz) = 7 AND a_vst.kontonummer = '1571')
)
WHERE b.buchungstyp = 'Ausgabe'
AND b.betrag_ust != 0
AND b.steuerart = 'normal'
AND ROUND(b.ust_satz) IN (7, 19)
AND b.parent_booking_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM bookings vst
WHERE vst.parent_booking_id = b.id
);

View file

@ -262,6 +262,7 @@ class BookingController
return;
}
\Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
$redirect = !empty($_POST['beleg_id'])
? '/belege/' . (int)$_POST['beleg_id']

View file

@ -19,7 +19,7 @@ class Beleg
OR s.name LIKE ?
OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ?
OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s
WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0) LIKE ?)';
WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0 AND bk2s.parent_booking_id IS NULL) LIKE ?)';
$s = '%' . $filter['search'] . '%';
$sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%';
$params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]);
@ -47,7 +47,7 @@ class Beleg
$offset = ($page - 1) * $perPage;
$sql = 'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL
) AS betrag_brutto,
fy.year as fiscal_year_nr,
s.name as supplier_name,
@ -77,7 +77,7 @@ class Beleg
return Database::fetchOne(
'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL
) AS betrag_brutto,
fy.year as fiscal_year_nr,
s.name as supplier_name,

View file

@ -131,7 +131,46 @@ class Booking
]
);
return (int)Database::lastInsertId();
$mainId = (int)Database::lastInsertId();
// VSt-Buchungszeile für normale Ausgaben mit 7 % oder 19 % anlegen
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
if ($data['buchungstyp'] === 'Ausgabe'
&& in_array($ustSatzInt, [7, 19], true)
&& $steuerart === 'normal'
&& abs($ust) > 0.0
) {
$vstKontonummer = $ustSatzInt === 19 ? '1576' : '1571';
$vstAcc = Account::findByKontonummer($vstKontonummer);
if ($vstAcc) {
$vstNr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$vstNr,
(int)$data['beleg_id'],
$mainId,
$data['buchungs_datum'],
'Ausgabe',
(int)$vstAcc['id'],
(int)$data['zahlungskonto_id'],
$ust,
0.0,
'normal',
0.0,
$ust,
'Vorsteuer zu ' . $nr,
$fy['id'],
]
);
}
}
return $mainId;
}
public static function reverse(int $id, string $reason): int
@ -182,6 +221,40 @@ class Booking
// Link original to reversal
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
// Verknüpfte VSt-Buchungszeilen ebenfalls stornieren
$vstBookings = Database::fetchAll(
'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL',
[$id]
);
foreach ($vstBookings as $vst) {
$vstNr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, is_reversal, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[
$vstNr,
$vst['beleg_id'],
$reversalId,
date('Y-m-d'),
$vst['buchungstyp'],
$vst['kategorie_konto_id'],
$vst['zahlungskonto_id'],
-1 * (float)$vst['betrag_netto'],
$vst['ust_satz'],
$vst['steuerart'],
-1 * (float)$vst['betrag_ust'],
-1 * (float)$vst['betrag_brutto'],
'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason,
$fy['id'],
]
);
$vstReversalId = (int)Database::lastInsertId();
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]);
}
return $reversalId;
}

View file

@ -17,21 +17,16 @@ use Steuerkram\Database;
* ustva_kz = '35' → Kz 35 (betrag_netto)
* ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto]
* ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto]
* (Kz 61/62 werden NICHT mehr über ustva_kz geroutet)
* ustva_kz = '61' → Kz 61 (betrag_netto) [Vorsteuer 19 % via Konto 1576]
* ustva_kz = '62' → Kz 62 (betrag_netto) [Vorsteuer 7 % via Konto 1571]
*
* 2. Buchungsbasiert (bookings.ust_satz, für Vorsteuer):
* buchungstyp = 'Ausgabe', ust_satz = 19 → Kz 61 (betrag_ust)
* buchungstyp = 'Ausgabe', ust_satz = 7 → Kz 62 (betrag_ust)
* Damit entfällt die Notwendigkeit separater VSt19/VSt7-Konten.
*
* 3. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto):
* 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.
* Buchungen mit EU-Steuerarten fließen ebenfalls NICHT in Kz 61/62.
*
* Abgeleitete Kennzahlen:
* Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb)
@ -43,13 +38,9 @@ class UstvaCalculator
/** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */
private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'];
/** Vorsteuer-Kennzahl je USt-Satz (buchungsbasiert). */
private const VST_SATZ_KZ = [19 => '61', 7 => '62'];
/**
* 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.
* Kz 61/62 sind hier nicht mehr enthalten (buchungsbasiert via VST_SATZ_KZ).
*/
private const KZ_ACCOUNT_MAP = [
'81' => ['81'],
@ -59,6 +50,8 @@ class UstvaCalculator
'35' => ['35'],
'41' => ['41'],
'44' => ['44'],
'61' => ['61'],
'62' => ['62'],
];
/**
@ -78,7 +71,8 @@ class UstvaCalculator
public function calculate(int $year, int $month): array
{
// ── 1. Kontobasierte Kennzahlen (EU-Steuerarten und Kz 61/62 ausgeschlossen) ──
// ── 1. Kontobasierte Kennzahlen (EU-Steuerarten ausgeschlossen) ─────────
// Kz 61 und 62 werden jetzt kontobasiert über 1576/1571 (betrag_netto) ermittelt.
$euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$accountRows = Database::fetchAll(
'SELECT a.ustva_kz,
@ -89,7 +83,6 @@ 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 a.ustva_kz NOT IN (\'61\', \'62\')
AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY a.ustva_kz',
array_merge([$year, $month], self::EU_STEUERARTEN)
@ -103,28 +96,7 @@ class UstvaCalculator
];
}
// ── 2. Buchungsbasierte Vorsteuer Kz 61/62 (via ust_satz) ────────────
$vstRows = Database::fetchAll(
'SELECT bk.ust_satz, SUM(bk.betrag_ust) AS summe_ust
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.buchungstyp = \'Ausgabe\'
AND bk.betrag_ust != 0
AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY bk.ust_satz',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
foreach ($vstRows as $row) {
$satz = (int)round((float)$row['ust_satz']);
$kzNr = self::VST_SATZ_KZ[$satz] ?? null;
if ($kzNr !== null) {
$byKz[$kzNr] = ['netto' => 0.0, 'ust' => (float)$row['summe_ust']];
}
}
// ── 3. Steuerart-basierte Kennzahlen ─────────────────────────────────
// ── 2. Steuerart-basierte Kennzahlen ─────────────────────────────────
$stRows = Database::fetchAll(
'SELECT bk.steuerart, SUM(bk.betrag_netto) AS summe_netto
FROM bookings bk
@ -140,7 +112,7 @@ class UstvaCalculator
$bySteuerart[$row['steuerart']] = (float)$row['summe_netto'];
}
// ── 4. Kennzahlen zusammensetzen ─────────────────────────────────────
// ── 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);
@ -154,8 +126,9 @@ class UstvaCalculator
$kz91 = round($kz89 * 0.19, 2);
$kz93 = $st('ig_erwerb_7');
$kz94 = round($kz93 * 0.07, 2);
$kz61 = $u('61');
$kz62 = $u('62');
// Kz 61/62 kontobasiert: betrag_netto der VSt-Buchungszeilen (1576/1571)
$kz61 = $n('61');
$kz62 = $n('62');
$kz67 = $kz91 + $kz94;
$kz66 = $kz61 + $kz62 + $kz67;
$kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66;
@ -182,17 +155,15 @@ class UstvaCalculator
/**
* Liefert alle Buchungszeilen, die zu einer Kennzahl beitragen (Drill-down).
* Kombiniert kontobasierte, buchungsbasierte und steuerart-basierte Quellen per UNION.
* Kombiniert kontobasierte und steuerart-basierte Quellen per UNION.
* Kz 61/62 werden kontobasiert über 1576/1571 geliefert (VSt-Buchungszeilen).
*/
public function fetchBookingsForKz(int $year, int $month, string $kz): array
{
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
// Kz 61/62: buchungsbasiert via ust_satz
$vstSatz = array_search($kz, self::VST_SATZ_KZ, true);
if (empty($accountKzList) && empty($steuerartList) && $vstSatz === false) {
if (empty($accountKzList) && empty($steuerartList)) {
return [];
}
@ -226,20 +197,6 @@ class UstvaCalculator
);
}
if ($vstSatz !== false) {
$euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$parts[] = $select
. ' AND bk.buchungstyp = \'Ausgabe\''
. ' AND bk.betrag_ust != 0'
. ' AND ROUND(bk.ust_satz) = ?'
. ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))';
$params = array_merge($params,
[$year, $month],
[$vstSatz],
self::EU_STEUERARTEN
);
}
if (!empty($steuerartList)) {
$ph = implode(',', array_fill(0, count($steuerartList), '?'));
$parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')';

View file

@ -1 +1 @@
1-260506
1-260510

View file

@ -55,7 +55,8 @@ class BelegGebuechtStatusTest extends TestCase
$beleg = Beleg::find($belegId);
$this->assertEquals(1, (int)($beleg['booked'] ?? 0), 'Nach Buchung: booked = 1 (abgeleitet)');
// Buchung löschen
// Buchung löschen (inkl. zugehöriger VSt-Buchungszeilen)
Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$buchungId]);
Database::execute('DELETE FROM bookings WHERE id = ?', [$buchungId]);
// Nach Löschen der Buchung: booked = 0

View file

@ -93,22 +93,30 @@ class StornoBuchungTest extends TestCase
);
// ── UStVA: Vorsteuer-KZ hebt sich auf ─────────────────────────
// Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61').
// Original: +19, Storno: -19 → Summe = 0 (oder weniger als andere Tests einbringen).
// Da weitere Tests parallel laufen können, prüfen wir nur, dass die Storno-Buchung
// als eigenständige Zeile in fetchBookingsForKz erscheint.
// Kz 61 wird jetzt kontobasiert über VSt-Buchungszeilen (Konto 1576) ermittelt.
// Zur Original-Ausgabe gehört eine VSt-Zeile (+19), zum Storno eine VSt-Storno-Zeile (-19).
// Wir prüfen, dass die VSt-Storno-Zeile im Drill-down erscheint.
$vstReversalRow = \Steuerkram\Database::fetchOne(
'SELECT * FROM bookings WHERE parent_booking_id = ? AND is_reversal = 1',
[$reversalId]
);
$this->assert($vstReversalRow !== false, 'VSt-Stornobuchung wurde zu Storno angelegt');
if ($vstReversalRow) {
$this->assertEquals(-19.00, round((float)$vstReversalRow['betrag_netto'], 2), 'VSt-Storno: betrag_netto = -19');
}
$ustvaCalc = new \Steuerkram\Services\UstvaCalculator();
$bookings = $ustvaCalc->fetchBookingsForKz($year, $month, '61');
$reversalFound = false;
$vstReversalFound = false;
foreach ($bookings as $b) {
if ((int)$b['id'] === $reversalId) {
$reversalFound = true;
$this->assertEquals(1, (int)$b['is_reversal'], 'UStVA-Drilldown: Stornobuchung is_reversal = 1');
$this->assertEquals(-19.00, round((float)$b['betrag_ust'], 2), 'UStVA-Drilldown: Storno betrag_ust = -19');
if ($vstReversalRow && (int)$b['id'] === (int)$vstReversalRow['id']) {
$vstReversalFound = true;
$this->assertEquals(1, (int)$b['is_reversal'], 'UStVA-Drilldown: VSt-Stornobuchung is_reversal = 1');
$this->assertEquals(-19.00, round((float)$b['betrag_netto'], 2), 'UStVA-Drilldown: VSt-Storno betrag_netto = -19');
break;
}
}
$this->assert($reversalFound, 'UStVA-Drilldown Kz 61: Stornobuchung erscheint in Detailansicht');
$this->assert($vstReversalFound, 'UStVA-Drilldown Kz 61: VSt-Stornobuchung erscheint in Detailansicht');
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
/**
* Test 28: Vorsteuerkonten-Buchungszeilen.
*
* Prüft, dass Ausgabe-Buchungen mit USt 7 % / 19 % automatisch eine
* VSt-Buchungszeile auf 1571 / 1576 erhalten, die UStVA Kz 61/62 daraus
* kontobasiert ableitet und ein Storno die VSt-Zeilen mitaufhebt.
*/
class VorsteuerkontenTest extends TestCase
{
protected string $testName = 'Test 28: Vorsteuerkonten-Buchungszeilen';
public function run(): void
{
$year = (int)date('Y');
$month = (int)date('n');
$datum = date('Y-m-d');
$zahlungsId = $this->findAccountId('1200');
$ausgabeId = $this->findAccountId('4920');
$konto1576 = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontonummer = '1576' AND aktiv = 1"
);
$konto1571 = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontonummer = '1571' AND aktiv = 1"
);
if (!$zahlungsId || !$ausgabeId || !$konto1576 || !$konto1571) {
$this->assert(false, 'Benötigte Konten nicht gefunden (1200, 4920, 1571, 1576)');
return;
}
$this->assert(true, 'Konten 1200, 4920, 1571, 1576 vorhanden');
// ── UStVA-Stand vor den Testbuchungen ─────────────────────────
$calc = new \Steuerkram\Services\UstvaCalculator();
$kzPre = $calc->calculate($year, $month);
// ── Beleg anlegen ──────────────────────────────────────────────
$belegId = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'VST-TEST-' . uniqid(),
'filename' => 'vst-test.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'vst-test-' . uniqid()),
'beleg_datum' => $datum,
'belegtyp' => 'Eingangsrechnung',
]);
// ── Ausgabe-Buchung 19 % (Netto=200, VSt=38) ──────────────────
$bookingId19 = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 200.00,
'ust_satz' => 19.0,
'buchungstext' => 'Test VSt 19%',
]);
// VSt-Buchungszeile muss auf 1576 angelegt worden sein
$vstRow19 = \Steuerkram\Database::fetchOne(
'SELECT bk.*, a.kontonummer FROM bookings bk
JOIN accounts a ON bk.kategorie_konto_id = a.id
WHERE bk.parent_booking_id = ?',
[$bookingId19]
);
$this->assert($vstRow19 !== false, 'VSt-Buchungszeile für 19%-Buchung angelegt');
$this->assertEquals('1576', $vstRow19['kontonummer'] ?? '', 'VSt-Buchungszeile auf Konto 1576');
$this->assertEquals(38.00, round((float)($vstRow19['betrag_netto'] ?? 0), 2), 'VSt betrag_netto = 38');
$this->assertEquals(0.00, round((float)($vstRow19['betrag_ust'] ?? 0), 2), 'VSt betrag_ust = 0');
// ── Ausgabe-Buchung 7 % (Netto=100, VSt=7) ────────────────────
$bookingId7 = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 100.00,
'ust_satz' => 7.0,
'buchungstext' => 'Test VSt 7%',
]);
$vstRow7 = \Steuerkram\Database::fetchOne(
'SELECT bk.*, a.kontonummer FROM bookings bk
JOIN accounts a ON bk.kategorie_konto_id = a.id
WHERE bk.parent_booking_id = ?',
[$bookingId7]
);
$this->assert($vstRow7 !== false, 'VSt-Buchungszeile für 7%-Buchung angelegt');
$this->assertEquals('1571', $vstRow7['kontonummer'] ?? '', 'VSt-Buchungszeile auf Konto 1571');
$this->assertEquals(7.00, round((float)($vstRow7['betrag_netto'] ?? 0), 2), 'VSt betrag_netto = 7');
// ── UStVA: Kz 61 und 62 aus Konten lesen ──────────────────────
$kzMit = $calc->calculate($year, $month);
$this->assert(
round((float)$kzMit['61']['steuer'] - (float)$kzPre['61']['steuer'], 2) >= 38.00,
'UStVA Kz 61 stieg durch 19%-Buchung um ≥ 38 €'
);
$this->assert(
round((float)$kzMit['62']['steuer'] - (float)$kzPre['62']['steuer'], 2) >= 7.00,
'UStVA Kz 62 stieg durch 7%-Buchung um ≥ 7 €'
);
$this->assert(
round((float)$kzMit['66']['steuer'] - (float)$kzPre['66']['steuer'], 2) >= 45.00,
'UStVA Kz 66 stieg um ≥ 45 €'
);
// ── Drill-down Kz 61 enthält die VSt-Buchungszeile ────────────
$drilldown = $calc->fetchBookingsForKz($year, $month, '61');
$vstFound = false;
foreach ($drilldown as $b) {
if ((int)$b['id'] === (int)$vstRow19['id']) {
$vstFound = true;
break;
}
}
$this->assert($vstFound, 'Drill-down Kz 61 enthält die VSt-Buchungszeile');
// ── Storno: VSt-Zeile muss mitaufgehoben werden ───────────────
$reversalId = \Steuerkram\Models\Booking::reverse($bookingId19, 'Testfall VSt-Storno');
$vstStornoRow = \Steuerkram\Database::fetchOne(
'SELECT * FROM bookings WHERE parent_booking_id = ? AND is_reversal = 1',
[$reversalId]
);
$this->assert($vstStornoRow !== false, 'VSt-Stornobuchung wurde angelegt');
$this->assertEquals(-38.00, round((float)($vstStornoRow['betrag_netto'] ?? 0), 2), 'VSt-Storno betrag_netto = -38');
// Original-VSt-Zeile muss als storniert markiert sein
$vstOriginal = \Steuerkram\Database::fetchOne(
'SELECT reversed_by FROM bookings WHERE id = ?',
[(int)$vstRow19['id']]
);
$this->assert(!empty($vstOriginal['reversed_by']), 'Original-VSt-Buchungszeile hat reversed_by gesetzt');
// UStVA Kz 61 nach Storno: netto Änderung gegenüber vor den Testbuchungen = 0
// (die 19%-Buchung und ihr Storno heben sich auf; 7%-Buchung geht in Kz 62)
$kzNach = $calc->calculate($year, $month);
$delta61 = round((float)$kzNach['61']['steuer'] - (float)$kzPre['61']['steuer'], 2);
$this->assertEquals(0.00, $delta61, 'Kz 61: Buchung+Storno heben sich gegenüber Ausgangslage auf');
}
}