From d3a6c379c9e591efdec4d75548e53aec9ef42aa1 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 10 May 2026 14:45:16 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Vorsteuerkonten=201571/1576=20aktivi?= =?UTF-8?q?eren,=20VSt-Buchungszeilen=20einf=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- migrations/044_konten_ig_erwerb_ust_vst.sql | 12 ++ .../045_vorsteuerkonten_buchungszeilen.sql | 78 +++++++++ src/Controllers/BookingController.php | 1 + src/Models/Beleg.php | 6 +- src/Models/Booking.php | 75 ++++++++- src/Services/UstvaCalculator.php | 77 ++------- src/version.txt | 2 +- tests/12_BelegGebuechtStatusTest.php | 3 +- tests/18_StornoBuchungTest.php | 28 ++-- tests/28_VorsteuerkontenTest.php | 148 ++++++++++++++++++ 10 files changed, 354 insertions(+), 76 deletions(-) create mode 100644 migrations/044_konten_ig_erwerb_ust_vst.sql create mode 100644 migrations/045_vorsteuerkonten_buchungszeilen.sql create mode 100644 tests/28_VorsteuerkontenTest.php diff --git a/migrations/044_konten_ig_erwerb_ust_vst.sql b/migrations/044_konten_ig_erwerb_ust_vst.sql new file mode 100644 index 0000000..4ed91a8 --- /dev/null +++ b/migrations/044_konten_ig_erwerb_ust_vst.sql @@ -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; diff --git a/migrations/045_vorsteuerkonten_buchungszeilen.sql b/migrations/045_vorsteuerkonten_buchungszeilen.sql new file mode 100644 index 0000000..0cb2ac8 --- /dev/null +++ b/migrations/045_vorsteuerkonten_buchungszeilen.sql @@ -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 + ); diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index abcd054..45fe8e4 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -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'] diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index 080e3d5..0faad49 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -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, diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 5f29771..a38aa85 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -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; } diff --git a/src/Services/UstvaCalculator.php b/src/Services/UstvaCalculator.php index 6349af8..3008aa7 100644 --- a/src/Services/UstvaCalculator.php +++ b/src/Services/UstvaCalculator.php @@ -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 . ')'; diff --git a/src/version.txt b/src/version.txt index 5814d3d..0420937 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260506 +1-260510 diff --git a/tests/12_BelegGebuechtStatusTest.php b/tests/12_BelegGebuechtStatusTest.php index 3d6e617..bdb835f 100644 --- a/tests/12_BelegGebuechtStatusTest.php +++ b/tests/12_BelegGebuechtStatusTest.php @@ -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 diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index ec9294e..731b4a5 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -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'); } } diff --git a/tests/28_VorsteuerkontenTest.php b/tests/28_VorsteuerkontenTest.php new file mode 100644 index 0000000..e74c6f2 --- /dev/null +++ b/tests/28_VorsteuerkontenTest.php @@ -0,0 +1,148 @@ +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'); + } +}