diff --git a/Changes.md b/Changes.md index 14373ef..be5ce28 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,26 @@ --- +## 2026-05-10 – Feature: ig. Erwerb 19 % ReverseCharge über Kindzeilen + +- Migration 047: ustva_kz='91' für Konto 1774; Kindzeilen (1574/1774) für bestehende ig_erwerb_19-Buchungen +- `Booking::create()`: steuerart=ig_erwerb_19 erzeugt zwei Kindzeilen – VSt auf 1574 (Kz 67), USt auf 1774 (Kz 91) +- `UstvaCalculator`: Kz 67/91 jetzt kontobasiert via 1574/1774; Kz 93/94 (ig_erwerb_7) entfernt +- Buchungsformular: Option „ig. Erwerb 7 %" entfernt +- Test 7 auf neue Logik angepasst; Test 30 neu + +--- + +## 2026-05-10 – Feature: USt-Buchungszeilen für Ausgangsrechnungen + +- Migration 046: Konten 1771 (USt 7 %) und 1776 (USt 19 %) erhalten `ustva_kz='84'`/'83'`; bestehende Einnahme-Buchungen mit USt erhalten nachträglich USt-Kindzeilen +- `Booking::create()`: Bei Einnahme-Buchungen mit 7/19 % und `steuerart=normal` wird analog zur VSt-Kindzeile eine USt-Zeile auf 1771/1776 angelegt +- `UstvaCalculator`: Kz 83/84 jetzt kontobasiert via 1771/1776 (`betrag_netto`), nicht mehr via `betrag_ust` der Erlöskonten; `KZ_ACCOUNT_MAP` aktualisiert +- `Invoice::cancel()`: Nur Hauptbuchungen (`parent_booking_id IS NULL`) werden direkt storniert; Kindzeilen werden von `Booking::reverse()` mitaufgehoben (verhindert Doppelstorno) +- Tests 10 und 27 an geänderte Buchungsstruktur angepasst; Test 29 neu + +--- + ## 2026-05-05 – Feature: Stornorechnung (§ 14 UStG) ### Hinzugefügt diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index 1ccb8ec..8eedbac 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -55,36 +55,42 @@ Diese Berechnung wird ausschließlich für **monatliche** Voranmeldungen unterst **Quelle 1 – kontobasiert** (`accounts.ustva_kz` des Kategorie-Kontos) SKR03-Erlös- und Steuerkonten tragen ihre Kennzahl direkt am Konto. Buchungen mit gefülltem `bookings.steuerart` werden aus dieser Quelle ausgeschlossen, um Doppelzählung mit Quelle 3 zu vermeiden. Konten mit `ustva_kz='61'` oder `'62'` werden **nicht mehr** kontobasiert ausgewertet (siehe Quelle 2). -**Quelle 2 – buchungsbasiert** (`bookings.ust_satz`, nur Vorsteuer) -Jede Buchung mit `buchungstyp='Ausgabe'` und `betrag_ust ≠ 0` fließt anhand ihres tatsächlichen USt-Satzes in Kz 61 (19 %) bzw. Kz 62 (7 %) ein. Damit ist es egal, ob das Konto auf USt 19 % oder 7 % gebucht wird – maßgeblich ist allein der Satz auf der Eingangsrechnung. EU-Buchungen (`steuerart` ∈ {ig_lieferung, export, ig_erwerb_19, ig_erwerb_7}) werden hier ausgeschlossen, da deren Vorsteuer über Kz 67 läuft. +**Quelle 2 – Steuerkonten-Buchungszeilen** (Kz 61/62/67/83/84/91 via Steuerkonten) +Normale Buchungen mit 7/19 % erzeugen automatisch Kindzeilen auf den zugehörigen Steuerkonten. Buchungen mit `steuerart=ig_erwerb_19` erzeugen zwei Kindzeilen auf 1574 (Kz 67) und 1774 (Kz 91) – jeweils `netto × 0,19`. Alle anderen EU-Steuerarten erhalten keine Kindzeilen. + +| Buchungstyp | Steuersatz | Kindzeile Konto | Kennzahl | +|---|---|---|---| +| Ausgabe, steuerart=normal | 19 % | 1576 | Kz 61 | +| Ausgabe, steuerart=normal | 7 % | 1571 | Kz 62 | +| Einnahme, steuerart=normal | 19 % | 1776 | Kz 83 | +| Einnahme, steuerart=normal | 7 % | 1771 | Kz 84 | +| Ausgabe, steuerart=ig_erwerb_19 | – | 1574 (netto×0,19) | Kz 67 | +| Ausgabe, steuerart=ig_erwerb_19 | – | 1774 (netto×0,19) | Kz 91 | **Quelle 3 – steuerartbasiert** (`bookings.steuerart`) Auslandssachverhalte unabhängig vom gebuchten Konto: - `ig_lieferung` → Kz 41 (steuerfreie ig. Lieferung, § 4 Nr. 1b UStG) - `export` → Kz 44 (steuerfreie Ausfuhr in Drittland, § 4 Nr. 1a UStG) -- `ig_erwerb_19` → Kz 89 (Bemessungsgrundlage) + Kz 91 (USt = 19 %) -- `ig_erwerb_7` → Kz 93 (Bemessungsgrundlage) + Kz 94 (USt = 7 %) +- `ig_erwerb_19` → Kz 89 (Bemessungsgrundlage) – Kz 91/67 laufen über Kindzeilen (Quelle 2) ### Implementierte Kennzahlen | Kz | Bezeichnung (ELSTER) | Materiell-rechtliche Grundlage | Quelle | Berechnung | |---|---|---|---|---| | **81** | Steuerpflichtige Umsätze 19 % – Bemessungsgrundlage | § 12 Abs. 1 UStG | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='81'` | -| **83** | USt auf steuerpflichtige Umsätze 19 % | § 12 Abs. 1 UStG | Konto | `SUM(betrag_ust)` WHERE `ustva_kz='81'` | +| **83** | USt auf steuerpflichtige Umsätze 19 % | § 12 Abs. 1 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='83'` (Konto 1776) | | **86** | Steuerpflichtige Umsätze 7 % – Bemessungsgrundlage | § 12 Abs. 2 UStG | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='86'` | -| **84** | USt auf steuerpflichtige Umsätze 7 % | § 12 Abs. 2 UStG | Konto | `SUM(betrag_ust)` WHERE `ustva_kz='86'` | +| **84** | USt auf steuerpflichtige Umsätze 7 % | § 12 Abs. 2 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='84'` (Konto 1771) | | **35** | Umsätze zu anderen Steuersätzen | Restkategorie für nicht-Standard-Sätze | Konto | `SUM(betrag_netto)` WHERE `ustva_kz='35'` | | **41** | Steuerfreie ig. Lieferungen mit USt-IdNr. | § 4 Nr. 1b i.V.m. § 6a UStG | Konto + Steuerart | `ustva_kz='41'` ODER `steuerart='ig_lieferung'` | | **44** | Steuerfreie Ausfuhrlieferungen Drittland | § 4 Nr. 1a i.V.m. § 6 UStG | Konto + Steuerart | `ustva_kz='44'` ODER `steuerart='export'` | | **89** | Ig. Erwerb 19 % – Bemessungsgrundlage | § 1a UStG, § 12 Abs. 1 UStG | Steuerart | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_19'` | -| **91** | USt auf ig. Erwerb 19 % | § 1a UStG | berechnet | `Kz 89 × 0,19` | -| **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | § 1a UStG, § 12 Abs. 2 UStG | Steuerart | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_7'` | -| **94** | USt auf ig. Erwerb 7 % | § 1a UStG | berechnet | `Kz 93 × 0,07` | -| **61** | Vorsteuerbeträge aus Eingangsrechnungen 19 % | § 15 Abs. 1 Nr. 1 UStG | Buchung | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe'` AND `ROUND(ust_satz)=19` | -| **62** | Vorsteuerbeträge aus Eingangsrechnungen 7 % | § 15 Abs. 1 Nr. 1 UStG | Buchung | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe'` AND `ROUND(ust_satz)=7` | -| **67** | Vorsteuer aus innergemeinschaftlichem Erwerb | § 15 Abs. 1 Nr. 3 UStG | abgeleitet | `Kz 91 + Kz 94` | +| **91** | USt auf ig. Erwerb 19 % | § 1a UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='91'` (Konto 1774) | +| **61** | Vorsteuerbeträge aus Eingangsrechnungen 19 % | § 15 Abs. 1 Nr. 1 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='61'` (Konto 1576) | +| **62** | Vorsteuerbeträge aus Eingangsrechnungen 7 % | § 15 Abs. 1 Nr. 1 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='62'` (Konto 1571) | +| **67** | Vorsteuer aus innergemeinschaftlichem Erwerb | § 15 Abs. 1 Nr. 3 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='67'` (Konto 1574) | | **66** | Vorsteuerbeträge gesamt | § 15 UStG | abgeleitet | `Kz 61 + Kz 62 + Kz 67` | -| **69** | Verbleibende USt-Vorauszahlung / Erstattung | § 18 Abs. 1 UStG | abgeleitet | `(Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66` | +| **69** | Verbleibende USt-Vorauszahlung / Erstattung | § 18 Abs. 1 UStG | abgeleitet | `(Kz 83 + Kz 84 + Kz 91) − Kz 66` | > **Hinweis zur Bezeichnung Kz 61/62:** Die offizielle ELSTER-Bezeichnung „Kz 61 = Vorsteuer aus innergemeinschaftlichem Erwerb" (§ 15 Abs. 1 Nr. 3 UStG) und „Kz 62 = Einfuhrumsatzsteuer" (§ 15 Abs. 1 Nr. 2 UStG) wird hier abweichend für Inlands-VSt 19 % bzw. 7 % verwendet. Diese Konvention stammt aus den frühen Migrationen und wurde nie auf die offiziellen Kz 66 (Inlandsvorsteuer 19 %) und Kz 66 (Inlandsvorsteuer 7 %) zurückgeführt. Für die ELSTER-Übertragung ist das unkritisch, weil das XML alle Vorsteuerwerte rechnerisch in Kz 66 zusammenfasst – materiell-rechtlich sollte dieser Punkt aber bei der nächsten Refaktorierung geprüft werden (siehe „Offene Punkte"). Migration 017 dokumentiert die Diskrepanz und fixt sie für VSt-Konten teilweise; Migration 022 hat sie für die buchungsbasierte Auswertung bewusst wieder eingeführt. @@ -99,8 +105,10 @@ Die folgenden Konten tragen heute eine UStVA-Kennzahl (Stand Migration 026): | **35** | (derzeit kein Konto) | Restkategorie | | **41** | 8125 (falls vorhanden) | Steuerfreie ig. Lieferung | | **44** | 8120 (falls vorhanden) | Ausfuhren | -| **61** | alle VSt19-Konten (3106, 3400, 4230, 4240, 4250, 4530–4590, 4600–4636, 4650, 4654, 4666, 4676, 4780, 4800–4809, 4900, 4920, 4925, 4930, 4945, 4950, 4955, 4960–4985, …) | wird vom Calculator **ignoriert**, Wert kommt aus `bookings.ust_satz` | -| **62** | alle VSt7-Konten (3108, 3300, 4651, 4656, 4940, …) | wird vom Calculator **ignoriert**, Wert kommt aus `bookings.ust_satz` | +| **61** | 1576 (Abziehbare Vorsteuer 19 %) | VSt-Buchungszeilen; Betriebsausgabe-Konten tragen diese Kennzahl **nicht** mehr | +| **62** | 1571 (Abziehbare Vorsteuer 7 %) | VSt-Buchungszeilen; Betriebsausgabe-Konten tragen diese Kennzahl **nicht** mehr | +| **83** | 1776 (Umsatzsteuer 19 %) | USt-Buchungszeilen für Ausgangsrechnungen | +| **84** | 1771 (Umsatzsteuer 7 %) | USt-Buchungszeilen für Ausgangsrechnungen | Eingangsumsätze ohne Vorsteuer (Konten 8100, 8105, 8110, 8120) sollten gemäß Migration 017 die Kennzahlen 48 (steuerfrei ohne VoSt-Abzug, § 4 Nr. 8–29) bzw. 43 (Ausfuhr Drittland) tragen. **Diese Kennzahlen werden vom `UstvaCalculator` aktuell nicht berechnet** – sie würden also weder im Bericht noch im XML auftauchen, selbst wenn ein Konto so gepflegt ist. Siehe „Offene Punkte". 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/migrations/046_ust_buchungszeilen.sql b/migrations/046_ust_buchungszeilen.sql new file mode 100644 index 0000000..49fd12a --- /dev/null +++ b/migrations/046_ust_buchungszeilen.sql @@ -0,0 +1,54 @@ +-- Migration 046: USt-Buchungszeilen für Ausgangsrechnungen einführen +-- +-- Hintergrund: Bisher wurde Umsatzsteuer für Einnahmen implizit über betrag_ust +-- der Hauptbuchung und ustva_kz des Kategorie-Kontos (81/86) ermittelt. +-- Ab jetzt erhält jede Einnahme-Buchung mit 7 % oder 19 % USt eine explizite +-- USt-Buchungszeile auf Konto 1771 (7 %) bzw. 1776 (19 %). +-- Kz 83/84 werden danach kontobasiert via 1771/1776 (betrag_netto) befüllt. + +-- ── 1. ustva_kz für 1771 und 1776 setzen ───────────────────────────────────── + +UPDATE accounts SET ustva_kz = '84' WHERE kontonummer = '1771'; +UPDATE accounts SET ustva_kz = '83' WHERE kontonummer = '1776'; + +-- ── 2. USt-Buchungszeilen für Bestandsbuchungen ─────────────────────────────── +-- Kriterien: buchungstyp=Einnahme, 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('UST-M-', LPAD(b.id, 8, '0')), + b.beleg_id, + b.id, + b.buchungs_datum, + 'Einnahme', + a_ust.id, + b.zahlungskonto_id, + b.betrag_ust, + 0.00, + 'normal', + 0.00, + b.betrag_ust, + CONCAT('USt zu ', b.buchungs_nr), + b.is_reversal, + b.fiscal_year_id +FROM bookings b +JOIN accounts a_ust ON ( + (ROUND(b.ust_satz) = 19 AND a_ust.kontonummer = '1776') + OR + (ROUND(b.ust_satz) = 7 AND a_ust.kontonummer = '1771') +) +WHERE b.buchungstyp = 'Einnahme' + 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 ust + WHERE ust.parent_booking_id = b.id + ); diff --git a/migrations/047_ige_rc_buchungszeilen.sql b/migrations/047_ige_rc_buchungszeilen.sql new file mode 100644 index 0000000..9c4f024 --- /dev/null +++ b/migrations/047_ige_rc_buchungszeilen.sql @@ -0,0 +1,82 @@ +-- Migration 047: ig. Erwerb 19 % ReverseCharge über Kindzeilen (1574/1774) +-- +-- Hintergrund: Bisher wurden Kz 91 (USt ig.E.) und Kz 67 (VSt ig.E.) durch den +-- UstvaCalculator aus betrag_netto × 0,19 der Hauptbuchung berechnet. +-- Ab jetzt erzeugt Booking::create() bei steuerart='ig_erwerb_19' zwei Kindzeilen: +-- - 1574 (Vorsteuer ig.E. 19 %, ustva_kz='67') für Kz 67 +-- - 1774 (USt ig.E. 19 %, ustva_kz='91') für Kz 91 + +-- ── 1. ustva_kz für 1774 auf '91' setzen ───────────────────────────────────── + +UPDATE accounts SET ustva_kz = '91' WHERE kontonummer = '1774'; + +-- ── 2. Kindzeilen für bestehende ig_erwerb_19 Hauptbuchungen anlegen ───────── +-- Idempotenz: WHERE NOT EXISTS verhindert Doppel-Einfügung + +-- 2a. VSt-Zeile auf 1574 (Kz 67) +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('IGE-VST-', LPAD(b.id, 8, '0')), + b.beleg_id, + b.id, + b.buchungs_datum, + b.buchungstyp, + a_vst.id, + b.zahlungskonto_id, + ROUND(b.betrag_netto * 0.19, 2), + 0.00, + 'normal', + 0.00, + ROUND(b.betrag_netto * 0.19, 2), + CONCAT('VSt ig.E. zu ', b.buchungs_nr), + b.is_reversal, + b.fiscal_year_id +FROM bookings b +JOIN accounts a_vst ON a_vst.kontonummer = '1574' +WHERE b.steuerart = 'ig_erwerb_19' + AND b.parent_booking_id IS NULL + AND b.betrag_netto != 0 + AND NOT EXISTS ( + SELECT 1 FROM bookings k + JOIN accounts ak ON k.kategorie_konto_id = ak.id + WHERE k.parent_booking_id = b.id AND ak.kontonummer = '1574' + ); + +-- 2b. USt-Zeile auf 1774 (Kz 91) +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('IGE-UST-', LPAD(b.id, 8, '0')), + b.beleg_id, + b.id, + b.buchungs_datum, + b.buchungstyp, + a_ust.id, + b.zahlungskonto_id, + ROUND(b.betrag_netto * 0.19, 2), + 0.00, + 'normal', + 0.00, + ROUND(b.betrag_netto * 0.19, 2), + CONCAT('USt ig.E. zu ', b.buchungs_nr), + b.is_reversal, + b.fiscal_year_id +FROM bookings b +JOIN accounts a_ust ON a_ust.kontonummer = '1774' +WHERE b.steuerart = 'ig_erwerb_19' + AND b.parent_booking_id IS NULL + AND b.betrag_netto != 0 + AND NOT EXISTS ( + SELECT 1 FROM bookings k + JOIN accounts ak ON k.kategorie_konto_id = ak.id + WHERE k.parent_booking_id = b.id AND ak.kontonummer = '1774' + ); diff --git a/public/index.php b/public/index.php index 422201f..55abdb7 100644 --- a/public/index.php +++ b/public/index.php @@ -44,6 +44,7 @@ $router->get('/dashboard', fn($p) => (new AccountController())->dashboard($p)); $router->get('/accounts', fn($p) => (new AccountController())->index($p)); $router->get('/accounts/new', fn($p) => (new AccountController())->create($p)); $router->post('/accounts', fn($p) => (new AccountController())->store($p)); +$router->get('/accounts/{id}/journal', fn($p) => (new AccountController())->journal($p)); $router->get('/accounts/{id}/edit', fn($p) => (new AccountController())->edit($p)); $router->post('/accounts/{id}', fn($p) => (new AccountController())->update($p)); $router->post('/accounts/{id}/delete', fn($p) => (new AccountController())->destroy($p)); diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php index 4aeb8e8..b56d6b5 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -198,6 +198,60 @@ class AccountController View::redirect($back, 'Konto gelöscht.'); } + public function journal(array $params): void + { + $id = (int)$params['id']; + $account = Account::find($id); + if (!$account) { http_response_code(404); return; } + + $availableYears = \Steuerkram\Database::fetchAll( + 'SELECT year FROM fiscal_years ORDER BY year DESC' + ); + $years = array_column($availableYears, 'year'); + if (empty($years)) { + $years = [(int)date('Y')]; + } + + $year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y'); + if (!in_array($year, $years, true)) { + $year = $years[0]; + } + + // Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen) + // ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto) + $bookings = \Steuerkram\Database::fetchAll( + 'SELECT bk.*, + k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, + z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name, + bl.beleg_nr + FROM bookings bk + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN accounts z ON bk.zahlungskonto_id = z.id + LEFT JOIN belege bl ON bk.beleg_id = bl.id + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + WHERE fy.year = ? + AND ( + bk.kategorie_konto_id = ? + OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL + AND bk.kategorie_konto_id != ?) + ) + ORDER BY bk.buchungs_datum, bk.id', + [$year, $id, $id, $id] + ); + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Konto ' . $account['kontonummer'] . ' – Journal', + 'content' => $this->renderView('accounts/journal', [ + 'account' => $account, + 'bookings' => $bookings, + 'year' => $year, + 'years' => $years, + 'accountId' => $id, + ]), + ]); + } + public function search(array $params): void { $q = trim($_GET['q'] ?? ''); 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..b4b6ddd 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -131,7 +131,92 @@ class Booking ] ); - return (int)Database::lastInsertId(); + $mainId = (int)Database::lastInsertId(); + + // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen + $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); + if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) + && in_array($ustSatzInt, [7, 19], true) + && $steuerart === 'normal' + && abs($ust) > 0.0 + ) { + $isAusgabe = $data['buchungstyp'] === 'Ausgabe'; + if ($isAusgabe) { + $steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571'; + $steuerText = 'Vorsteuer zu ' . $nr; + } else { + $steuerKontonummer = $ustSatzInt === 19 ? '1776' : '1771'; + $steuerText = 'USt zu ' . $nr; + } + $steuerAcc = Account::findByKontonummer($steuerKontonummer); + if ($steuerAcc) { + $steuerNr = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $steuerNr, + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + $data['buchungstyp'], + (int)$steuerAcc['id'], + (int)$data['zahlungskonto_id'], + $ust, + 0.0, + 'normal', + 0.0, + $ust, + $steuerText, + $fy['id'], + ] + ); + } + } + + // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf 1574 (VSt, Kz 67) und 1774 (USt, Kz 91) + if ($data['buchungstyp'] === 'Ausgabe' + && $steuerart === 'ig_erwerb_19' + && abs($netto) > 0.0 + ) { + $igeUst = round($netto * 0.19, 2); + foreach ([ + ['1574', 'VSt ig.E. zu '], + ['1774', 'USt ig.E. zu '], + ] as [$kontonr, $prefix]) { + $igeAcc = Account::findByKontonummer($kontonr); + if ($igeAcc) { + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + self::generateBuchungsNr(), + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + 'Ausgabe', + (int)$igeAcc['id'], + (int)$data['zahlungskonto_id'], + $igeUst, + 0.0, + 'normal', + 0.0, + $igeUst, + $prefix . $nr, + $fy['id'], + ] + ); + } + } + } + + return $mainId; } public static function reverse(int $id, string $reason): int @@ -182,6 +267,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/Models/Invoice.php b/src/Models/Invoice.php index a82cc4b..f326f6a 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -203,7 +203,7 @@ class Invoice // Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen if (!empty($invoice['beleg_id'])) { $bookings = Database::fetchAll( - 'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 0 AND reversed_by IS NULL', + 'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 0 AND reversed_by IS NULL AND parent_booking_id IS NULL', [(int)$invoice['beleg_id']] ); foreach ($bookings as $bk) { diff --git a/src/Services/UstvaCalculator.php b/src/Services/UstvaCalculator.php index 6349af8..d1640ae 100644 --- a/src/Services/UstvaCalculator.php +++ b/src/Services/UstvaCalculator.php @@ -12,53 +12,50 @@ use Steuerkram\Database; * Zwei Quellen fließen in die Berechnung ein: * * 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 = '81' → Kz 81 (betrag_netto) + * ustva_kz = '83' → Kz 83 (betrag_netto) [USt 19 % via Konto 1776] + * ustva_kz = '86' → Kz 86 (betrag_netto) + * ustva_kz = '84' → Kz 84 (betrag_netto) [USt 7 % via Konto 1771] * 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] + * ustva_kz = '67' → Kz 67 (betrag_netto) [VSt ig. Erwerb 19 % via Konto 1574] + * ustva_kz = '91' → Kz 91 (betrag_netto) [USt ig. Erwerb 19 % via Konto 1774] * - * 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) + * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto, Bemessungsgrundlage) * 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) * Kz 66 = Kz 61 + Kz 62 + Kz 67 (Vorsteuer gesamt) - * Kz 69 = (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 (Zahllast) + * Kz 69 = (Kz 83 + Kz 84 + Kz 91) − Kz 66 (Zahllast) */ class UstvaCalculator { - /** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */ + /** Steuerart-Werte die aus der kontobasierten Berechnung ausgeschlossen 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'], - '83' => ['81'], + '83' => ['83'], '86' => ['86'], - '84' => ['86'], + '84' => ['84'], '35' => ['35'], '41' => ['41'], '44' => ['44'], + '61' => ['61'], + '62' => ['62'], + '67' => ['67'], + '91' => ['91'], ]; /** @@ -68,17 +65,13 @@ class UstvaCalculator '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 { - // ── 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 +82,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 +95,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,25 +111,25 @@ 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); - $kz81 = $n('81'); $kz83 = $u('81'); - $kz86 = $n('86'); $kz84 = $u('86'); + // Kz 83/84 kontobasiert: betrag_netto der USt-Buchungszeilen (1776/1771) + $kz81 = $n('81'); $kz83 = $n('83'); + $kz86 = $n('86'); $kz84 = $n('84'); $kz35 = $n('35'); $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; + // Kz 61/62/67/91 kontobasiert via Steuerkonten (1576/1571/1574/1774) + $kz61 = $n('61'); + $kz62 = $n('62'); + $kz67 = $n('67'); + $kz91 = $n('91'); $kz66 = $kz61 + $kz62 + $kz67; - $kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66; + $kz69 = ($kz83 + $kz84 + $kz91) - $kz66; return [ '81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], @@ -170,11 +141,9 @@ class UstvaCalculator '44' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz44, 2), 'steuer' => null], '89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null], '91' => ['name' => 'USt auf ig. Erwerb 19 %', 'betrag' => null, 'steuer' => round($kz91, 2)], - '93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null], - '94' => ['name' => 'USt auf ig. Erwerb 7 %', 'betrag' => null, 'steuer' => round($kz94, 2)], '61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => round($kz61, 2)], '62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => round($kz62, 2)], - '67' => ['name' => 'Vorsteuer aus ig. Erwerb', 'betrag' => null, 'steuer' => round($kz67, 2)], + '67' => ['name' => 'Vorsteuer aus ig. Erwerb 19 %', 'betrag' => null, 'steuer' => round($kz67, 2)], '66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => round($kz66, 2)], '69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => round($kz69, 2)], ]; @@ -182,17 +151,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 +193,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/Views/accounts/journal.php b/src/Views/accounts/journal.php new file mode 100644 index 0000000..000ce28 --- /dev/null +++ b/src/Views/accounts/journal.php @@ -0,0 +1,124 @@ + View::h($v); +$fmt = fn($v) => number_format(abs((float)$v), 2, ',', '.'); + +// Jahr-Navigation +$prevYear = $year - 1; +$nextYear = $year + 1; +$hasPrev = in_array($prevYear, $years, true); +$hasNext = in_array($nextYear, $years, true); +?> + +
+
+
+
+ – +
+
+
+
+ + ← + + + + + + → + + + + ← Kontenrahmen +
+
+ + +
+

Keine Buchungen für dieses Konto in .

+
+ + +
+ + + + + + + + + + + + + + + > + + + + + + + + + + + + + + + + +
DatumNummerBelegTypGegenkontoTextWert
+ + + + – + + + + + + + € +
Kontostand ( Buchungen) + € +
+
+ +
diff --git a/src/Views/accounts/list.php b/src/Views/accounts/list.php index f25e1ee..1e15359 100644 --- a/src/Views/accounts/list.php +++ b/src/Views/accounts/list.php @@ -61,6 +61,7 @@
+ Journal Bearbeiten
diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index a482cad..489a66e 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -212,9 +212,7 @@ $suppliers = $suppliers ?? []; Typ Kategorie Zahlungskonto - Netto - USt - Brutto + Wert Text Anlage @@ -232,8 +230,6 @@ $suppliers = $suppliers ?? [];

- % - @@ -292,7 +288,7 @@ $suppliers = $suppliers ?? []; - Noch keine Buchungen. + Noch keine Buchungen. @@ -387,8 +383,7 @@ $suppliers = $suppliers ?? []; 'normal' => 'Normal (Inland)', 'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)', 'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)', - 'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % (Kz 89/91)', - 'ig_erwerb_7' => 'Innergemeinschaftlicher Erwerb 7 % (Kz 93/94)', + 'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % – ReverseCharge (Kz 89/91/67)', ]; $currentSteuerart = $formData['steuerart'] ?? 'normal'; foreach ($steuerartOptionen as $val => $label): ?> @@ -735,15 +730,12 @@ function updateAutoUst() { const hiddenEl = document.getElementById('ust_satz_hidden'); if (autoUst !== null) { - ustSatzEl.value = autoUst; - ustSatzEl.disabled = true; - ustSatzEl.title = 'Automatisch durch Steuercode des Kontos gesetzt'; + ustSatzEl.value = autoUst; if (hiddenEl) hiddenEl.value = autoUst; - } else { - ustSatzEl.disabled = false; - ustSatzEl.title = ''; - if (hiddenEl) hiddenEl.value = ustSatzEl.value; } + ustSatzEl.disabled = false; + ustSatzEl.title = ''; + if (hiddenEl && autoUst === null) hiddenEl.value = ustSatzEl.value; updateSteuerartVisibility(); updateNettoAnzeige(); } diff --git a/src/Views/tax/ustva_show.php b/src/Views/tax/ustva_show.php index 5bcdc3f..cbac3bd 100644 --- a/src/Views/tax/ustva_show.php +++ b/src/Views/tax/ustva_show.php @@ -32,7 +32,7 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' $sections = [ 'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'], 'Umsatzsteuer auf Umsätze' => ['83', '84'], - 'Innergemeinschaftlicher Erwerb' => ['89', '91', '93', '94'], + 'Innergemeinschaftlicher Erwerb' => ['89', '91'], 'Vorsteuerbeträge' => ['61', '62', '67', '66'], 'Verbleibende Zahllast' => ['69'], ]; @@ -98,6 +98,14 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
Zusammenfassung
+ @@ -108,17 +116,27 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' - - + + + + + + + + + + + + - - + + - - +
Umsatz 19 %
USt gesamtUSt (Kz 83+84)
ig. Erwerb USt (Kz 91)
USt gesamt
Vorsteuer– Vorsteuer (Kz 66)–
= 0 ? 'Vorauszahlung' : 'Erstattung' ?> - + = 0 ? 'Vorauszahlung' : 'Erstattung' ?> +
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/05_TaxSummenTest.php b/tests/05_TaxSummenTest.php index ef35c1c..12adcdf 100644 --- a/tests/05_TaxSummenTest.php +++ b/tests/05_TaxSummenTest.php @@ -129,9 +129,10 @@ class TaxSummenTest extends TestCase 'UStVA Kz 66 (Vorsteuer gesamt): ≥ Kz 61' ); - // Kz 69 Berechnung: USt gesamt - Vorsteuer gesamt + // Kz 69 Berechnung: (USt-Umsätze + USt ig.Erwerb) - Vorsteuer gesamt $erwKz69 = round( - ((float)$kz['83']['steuer'] + (float)$kz['84']['steuer']) - (float)$kz['66']['steuer'], + ((float)$kz['83']['steuer'] + (float)$kz['84']['steuer'] + (float)($kz['91']['steuer'] ?? 0)) + - (float)$kz['66']['steuer'], 2 ); $this->assertEquals($erwKz69, (float)$kz['69']['steuer'], 'UStVA Kz 69 (Zahllast) korrekt berechnet'); diff --git a/tests/07_UstvaAuslandTest.php b/tests/07_UstvaAuslandTest.php index 92e6ef3..6adcc32 100644 --- a/tests/07_UstvaAuslandTest.php +++ b/tests/07_UstvaAuslandTest.php @@ -6,7 +6,7 @@ declare(strict_types=1); * Test 7: UStVA-Kennzahlen für Auslandsumsätze und ig. Erwerb. * * Prüft, dass steuerart-Buchungen korrekt in die UStVA-Kennzahlen - * 41, 44, 89, 91, 93, 94 und 67 einfließen. + * 41, 44, 89, 91 und 67 einfließen. */ class UstvaAuslandTest extends TestCase { @@ -29,22 +29,24 @@ class UstvaAuslandTest extends TestCase } $this->assert(true, 'Konten vorhanden'); - // Zweites Zahlungskonto für Ausgabe-Buchungen $bankId = \Steuerkram\Database::fetchOne( "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 AND id != ? LIMIT 1", [$zahlungId] ); $bankId = $bankId ? (int)$bankId['id'] : $zahlungId; + $calc = new \Steuerkram\Services\UstvaCalculator(); + $kzPre = $calc->calculate($year, $month); + // ── 1. Steuerfreie ig. Lieferung → Kz 41 ────────────────────────── $belegIgL = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-IGL-' . uniqid(), - 'filename' => 'test-igl.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'test-igl'), - 'beleg_datum' => $datum, - 'belegtyp' => 'Ausgangsrechnung', + 'beleg_nr' => 'TEST-IGL-' . uniqid(), + 'filename' => 'test-igl.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-igl'), + 'beleg_datum' => $datum, + 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ 'beleg_id' => $belegIgL, @@ -60,13 +62,13 @@ class UstvaAuslandTest extends TestCase // ── 2. Steuerfreie Ausfuhr → Kz 44 ─────────────────────────────── $belegExp = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-EXP-' . uniqid(), - 'filename' => 'test-exp.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'test-exp'), - 'beleg_datum' => $datum, - 'belegtyp' => 'Ausgangsrechnung', + 'beleg_nr' => 'TEST-EXP-' . uniqid(), + 'filename' => 'test-exp.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-exp'), + 'beleg_datum' => $datum, + 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ 'beleg_id' => $belegExp, @@ -82,13 +84,13 @@ class UstvaAuslandTest extends TestCase // ── 3. Ig. Erwerb 19 % → Kz 89/91/67 ───────────────────────────── $belegIgE19 = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-IGE19-' . uniqid(), - 'filename' => 'test-ige19.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'test-ige19'), - 'beleg_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', + 'beleg_nr' => 'TEST-IGE19-' . uniqid(), + 'filename' => 'test-ige19.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-ige19'), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ 'beleg_id' => $belegIgE19, @@ -102,31 +104,8 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test ig. Erwerb 19%', ]); - // ── 4. Ig. Erwerb 7 % → Kz 93/94/67 ────────────────────────────── - $belegIgE7 = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-IGE7-' . uniqid(), - 'filename' => 'test-ige7.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'test-ige7'), - 'beleg_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', - ]); - \Steuerkram\Models\Booking::create([ - 'beleg_id' => $belegIgE7, - 'buchungs_datum' => $datum, - 'buchungstyp' => 'Ausgabe', - 'steuerart' => 'ig_erwerb_7', - 'kategorie_konto_id' => $ausgabeId, - 'zahlungskonto_id' => $bankId, - 'betrag_netto' => 300.00, - 'ust_satz' => 0.0, - 'buchungstext' => 'Test ig. Erwerb 7%', - ]); - - // ── UStVA berechnen und Kennzahlen prüfen ───────────────────────── - $calc = new \Steuerkram\Services\UstvaCalculator(); - $kz = $calc->calculate($year, $month); + // ── UStVA berechnen und Delta prüfen ────────────────────────────── + $kz = $calc->calculate($year, $month); // Kz 41: ig. Lieferung 1000 € $this->assert( @@ -140,58 +119,38 @@ class UstvaAuslandTest extends TestCase 'Kz 44 (Ausfuhr): Betrag ≥ 2.000 €' ); - // Kz 89: ig. Erwerb 19% Bemessungsgrundlage 500 € + // Kz 89: ig. Erwerb 19% Bemessungsgrundlage +500 € $this->assert( - (float)$kz['89']['betrag'] >= 500.00, - 'Kz 89 (ig. Erwerb 19% Bmgl.): Betrag ≥ 500 €' + round((float)$kz['89']['betrag'] - (float)$kzPre['89']['betrag'], 2) >= 500.00, + 'Kz 89 (ig. Erwerb 19% Bmgl.): Delta ≥ 500 €' ); - // Kz 91: USt auf ig. Erwerb 19% = 500 * 0.19 = 95 € + // Kz 91: USt ig. Erwerb kontobasiert (1774) +95 € $this->assertEquals( - round(500.00 * 0.19, 2), - (float)$kz['91']['steuer'], - 'Kz 91 (USt ig. Erwerb 19%): Steuer = 95,00 €' + 95.00, + round((float)$kz['91']['steuer'] - (float)$kzPre['91']['steuer'], 2), + 'Kz 91 (USt ig. Erwerb 19%): Delta = 95,00 €' ); - // Kz 93: ig. Erwerb 7% Bemessungsgrundlage 300 € - $this->assert( - (float)$kz['93']['betrag'] >= 300.00, - 'Kz 93 (ig. Erwerb 7% Bmgl.): Betrag ≥ 300 €' - ); - - // Kz 94: USt auf ig. Erwerb 7% = 300 * 0.07 = 21 € + // Kz 67: VSt ig. Erwerb kontobasiert (1574) +95 € $this->assertEquals( - round(300.00 * 0.07, 2), - (float)$kz['94']['steuer'], - 'Kz 94 (USt ig. Erwerb 7%): Steuer = 21,00 €' + 95.00, + round((float)$kz['67']['steuer'] - (float)$kzPre['67']['steuer'], 2), + 'Kz 67 (VSt ig. Erwerb): Delta = 95,00 €' ); - // Kz 67: Vorsteuer aus ig. Erwerb = Kz 91 + Kz 94 = 116 € - $erwartetKz67 = round((float)$kz['91']['steuer'] + (float)$kz['94']['steuer'], 2); + // ReverseCharge ist zahllastneutral: Kz 69 unverändert $this->assertEquals( - $erwartetKz67, - (float)$kz['67']['steuer'], - 'Kz 67 (Vorsteuer ig. Erwerb) = Kz 91 + Kz 94' + 0.00, + round((float)$kz['69']['steuer'] - (float)$kzPre['69']['steuer'], 2), + 'Kz 69 (Zahllast) durch ig. Erwerb nicht verändert' ); - // Kz 69 Plausibilitätsprüfung: ig. Erwerb neutralisiert sich durch Kz 67 - // USt auf Umsätze (83+84) + ig. Erwerb (91+94) - Vorsteuer (66) = 69 - $ust = (float)$kz['83']['steuer'] + (float)$kz['84']['steuer'] - + (float)$kz['91']['steuer'] + (float)$kz['94']['steuer']; - $erwartetKz69 = round($ust - (float)$kz['66']['steuer'], 2); - $this->assertEquals( - $erwartetKz69, - (float)$kz['69']['steuer'], - 'Kz 69 (Zahllast) korrekt berechnet' - ); - - // ig. Erwerb darf NICHT in Kz 35 oder Kz 61/62 landen - // (kz35 enthält nur normale 0%-Einnahmen, kz61 nur normale Vorsteuer) - // Wir buchten keine normalen 0%-Einnahmen, also muss Kz 35 = 0 sein + // ig. Erwerb darf nicht in Kz 35 landen $this->assertEquals( 0.0, (float)$kz['35']['betrag'], - 'Kz 35 (steuerfreie Inlandsumsätze) = 0 (kein normaler 0%-Umsatz gebucht)' + 'Kz 35 (steuerfreie Inlandsumsätze) = 0' ); } diff --git a/tests/10_IstVersteuerungTest.php b/tests/10_IstVersteuerungTest.php index 9f15580..391356c 100644 --- a/tests/10_IstVersteuerungTest.php +++ b/tests/10_IstVersteuerungTest.php @@ -100,8 +100,11 @@ class IstVersteuerungTest extends TestCase $this->assertGreaterThan(0, $buchungId, 'Buchung mit Zahldatum erfasst'); // Buchung hat das Zahldatum – nicht das Rechnungsdatum - $buchungenNachZahlung = \Steuerkram\Models\Booking::forBeleg($belegId); - $this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Buchung vorhanden'); + $buchungenNachZahlung = array_values(array_filter( + \Steuerkram\Models\Booking::forBeleg($belegId), + fn($b) => $b['parent_booking_id'] === null + )); + $this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Hauptbuchung vorhanden'); $bk = $buchungenNachZahlung[0]; $this->assertEquals($zahlDatum, $bk['buchungs_datum'], 'Buchungsdatum ist das Zahldatum'); 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/27_StornorechnungTest.php b/tests/27_StornorechnungTest.php index 73bceaa..e33de15 100644 --- a/tests/27_StornorechnungTest.php +++ b/tests/27_StornorechnungTest.php @@ -146,10 +146,10 @@ class StornorechnungTest extends TestCase // ── 5. Stornobuchung muss am Originalbeleg hängen ───────────── $reversalRows = \Steuerkram\Database::fetchAll( - 'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1', + 'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1 AND parent_booking_id IS NULL', [$belegId] ); - $this->assertEquals(1, count($reversalRows), 'Genau eine Stornobuchung am Originalbeleg'); + $this->assertEquals(1, count($reversalRows), 'Genau eine Hauptstornobuchung am Originalbeleg'); $reversal = $reversalRows[0]; $this->assertEquals(-250.00, round((float)$reversal['betrag_netto'], 2), 'Stornobuchung Netto = -250'); $this->assertEquals(-47.50, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung USt = -47,50'); 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'); + } +} diff --git a/tests/29_UmsatzsteuerkontenTest.php b/tests/29_UmsatzsteuerkontenTest.php new file mode 100644 index 0000000..a183250 --- /dev/null +++ b/tests/29_UmsatzsteuerkontenTest.php @@ -0,0 +1,143 @@ +findAccountId('1200'); + $einnahmeId = $this->findAccountId('8400'); + $konto1776 = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = '1776' AND aktiv = 1" + ); + $konto1771 = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = '1771' AND aktiv = 1" + ); + + if (!$zahlungsId || !$einnahmeId || !$konto1776 || !$konto1771) { + $this->assert(false, 'Benötigte Konten nicht gefunden (1200, 8400, 1771, 1776)'); + return; + } + $this->assert(true, 'Konten 1200, 8400, 1771, 1776 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' => 'UST-TEST-' . uniqid(), + 'filename' => 'ust-test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'ust-test-' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Ausgangsrechnung', + ]); + + // ── Einnahme-Buchung 19 % (Netto=500, USt=95) ───────────────── + $bookingId19 = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $einnahmeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 500.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test USt 19%', + ]); + + // USt-Buchungszeile muss auf 1776 angelegt worden sein + $ustRow19 = \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($ustRow19 !== false, 'USt-Buchungszeile für 19%-Buchung angelegt'); + $this->assertEquals('1776', $ustRow19['kontonummer'] ?? '', 'USt-Buchungszeile auf Konto 1776'); + $this->assertEquals(95.00, round((float)($ustRow19['betrag_netto'] ?? 0), 2), 'USt betrag_netto = 95'); + $this->assertEquals(0.00, round((float)($ustRow19['betrag_ust'] ?? 0), 2), 'USt betrag_ust = 0'); + + // ── Einnahme-Buchung 7 % (Netto=200, USt=14) ────────────────── + $bookingId7 = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $einnahmeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 200.00, + 'ust_satz' => 7.0, + 'buchungstext' => 'Test USt 7%', + ]); + + $ustRow7 = \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($ustRow7 !== false, 'USt-Buchungszeile für 7%-Buchung angelegt'); + $this->assertEquals('1771', $ustRow7['kontonummer'] ?? '', 'USt-Buchungszeile auf Konto 1771'); + $this->assertEquals(14.00, round((float)($ustRow7['betrag_netto'] ?? 0), 2), 'USt betrag_netto = 14'); + + // ── UStVA: Kz 83 und 84 aus Konten lesen ────────────────────── + $kzMit = $calc->calculate($year, $month); + + $this->assert( + round((float)$kzMit['83']['steuer'] - (float)$kzPre['83']['steuer'], 2) >= 95.00, + 'UStVA Kz 83 stieg durch 19%-Buchung um ≥ 95 €' + ); + $this->assert( + round((float)$kzMit['84']['steuer'] - (float)$kzPre['84']['steuer'], 2) >= 14.00, + 'UStVA Kz 84 stieg durch 7%-Buchung um ≥ 14 €' + ); + + // ── Drill-down Kz 83 enthält die USt-Buchungszeile ──────────── + $drilldown = $calc->fetchBookingsForKz($year, $month, '83'); + $ustFound = false; + foreach ($drilldown as $b) { + if ((int)$b['id'] === (int)$ustRow19['id']) { + $ustFound = true; + break; + } + } + $this->assert($ustFound, 'Drill-down Kz 83 enthält die USt-Buchungszeile'); + + // ── Storno: USt-Zeile muss mitaufgehoben werden ─────────────── + $reversalId = \Steuerkram\Models\Booking::reverse($bookingId19, 'Testfall USt-Storno'); + + $ustStornoRow = \Steuerkram\Database::fetchOne( + 'SELECT * FROM bookings WHERE parent_booking_id = ? AND is_reversal = 1', + [$reversalId] + ); + $this->assert($ustStornoRow !== false, 'USt-Stornobuchung wurde angelegt'); + $this->assertEquals(-95.00, round((float)($ustStornoRow['betrag_netto'] ?? 0), 2), 'USt-Storno betrag_netto = -95'); + + // Original-USt-Zeile muss als storniert markiert sein + $ustOriginal = \Steuerkram\Database::fetchOne( + 'SELECT reversed_by FROM bookings WHERE id = ?', + [(int)$ustRow19['id']] + ); + $this->assert(!empty($ustOriginal['reversed_by']), 'Original-USt-Buchungszeile hat reversed_by gesetzt'); + + // UStVA Kz 83 nach Storno: netto Änderung = nur noch 7%-Buchung sichtbar, 19% aufgehoben + $kzNach = $calc->calculate($year, $month); + $delta83 = round((float)$kzNach['83']['steuer'] - (float)$kzPre['83']['steuer'], 2); + $this->assertEquals(0.00, $delta83, 'Kz 83: Buchung+Storno heben sich gegenüber Ausgangslage auf'); + } +} diff --git a/tests/30_IgErwerbReverseChargeTest.php b/tests/30_IgErwerbReverseChargeTest.php new file mode 100644 index 0000000..d5b69e0 --- /dev/null +++ b/tests/30_IgErwerbReverseChargeTest.php @@ -0,0 +1,126 @@ +findAccountId('1200'); + $ausgabeId = $this->findAccountId('4120'); + $konto1574 = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = '1574' AND aktiv = 1" + ); + $konto1774 = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = '1774' AND aktiv = 1" + ); + + if (!$zahlungsId || !$ausgabeId || !$konto1574 || !$konto1774) { + $this->assert(false, 'Benötigte Konten nicht gefunden (1200, 4120, 1574, 1774)'); + return; + } + $this->assert(true, 'Konten 1200, 4120, 1574, 1774 vorhanden'); + + $calc = new \Steuerkram\Services\UstvaCalculator(); + $kzPre = $calc->calculate($year, $month); + + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'IGE-TEST-' . uniqid(), + 'filename' => 'ige-test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'ige-test-' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', + ]); + + // Ausgabe-Buchung 0 % USt, steuerart=ig_erwerb_19, Netto=1000 → IGE-USt=190 + $bookingId = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 1000.00, + 'ust_satz' => 0.0, + 'steuerart' => 'ig_erwerb_19', + 'buchungstext' => 'Test ig. Erwerb 19%', + ]); + + // Zwei Kindzeilen müssen angelegt worden sein + $kinder = \Steuerkram\Database::fetchAll( + 'SELECT bk.*, a.kontonummer FROM bookings bk + JOIN accounts a ON bk.kategorie_konto_id = a.id + WHERE bk.parent_booking_id = ? + ORDER BY a.kontonummer', + [$bookingId] + ); + $this->assertEquals(2, count($kinder), 'Genau zwei Kindzeilen angelegt'); + + $kind1574 = null; + $kind1774 = null; + foreach ($kinder as $k) { + if ($k['kontonummer'] === '1574') $kind1574 = $k; + if ($k['kontonummer'] === '1774') $kind1774 = $k; + } + + $this->assert($kind1574 !== null, 'Kindzeile auf Konto 1574 vorhanden'); + $this->assertEquals(190.00, round((float)($kind1574['betrag_netto'] ?? 0), 2), '1574 betrag_netto = 190'); + $this->assertEquals(0.00, round((float)($kind1574['betrag_ust'] ?? 0), 2), '1574 betrag_ust = 0'); + + $this->assert($kind1774 !== null, 'Kindzeile auf Konto 1774 vorhanden'); + $this->assertEquals(190.00, round((float)($kind1774['betrag_netto'] ?? 0), 2), '1774 betrag_netto = 190'); + $this->assertEquals(0.00, round((float)($kind1774['betrag_ust'] ?? 0), 2), '1774 betrag_ust = 0'); + + // UStVA: Kz 89 (Bemessungsgrundlage), Kz 91 (USt), Kz 67 (VSt) + $kzMit = $calc->calculate($year, $month); + + $delta89 = round((float)$kzMit['89']['betrag'] - (float)$kzPre['89']['betrag'], 2); + $delta91 = round((float)$kzMit['91']['steuer'] - (float)$kzPre['91']['steuer'], 2); + $delta67 = round((float)$kzMit['67']['steuer'] - (float)$kzPre['67']['steuer'], 2); + $delta69 = round((float)$kzMit['69']['steuer'] - (float)$kzPre['69']['steuer'], 2); + + $this->assertEquals(1000.00, $delta89, 'Kz 89 stieg um 1000 €'); + $this->assertEquals(190.00, $delta91, 'Kz 91 stieg um 190 €'); + $this->assertEquals(190.00, $delta67, 'Kz 67 stieg um 190 €'); + $this->assertEquals(0.00, $delta69, 'Kz 69 (Zahllast) unverändert – ReverseCharge neutral'); + + // Drill-down Kz 91 enthält die 1774-Kindzeile + $drilldown = $calc->fetchBookingsForKz($year, $month, '91'); + $found = false; + foreach ($drilldown as $b) { + if ((int)$b['id'] === (int)$kind1774['id']) { $found = true; break; } + } + $this->assert($found, 'Drill-down Kz 91 enthält die 1774-Kindzeile'); + + // Storno: beide Kindzeilen werden mitaufgehoben + $reversalId = \Steuerkram\Models\Booking::reverse($bookingId, 'Test IGE-Storno'); + + $stornoKinder = \Steuerkram\Database::fetchAll( + 'SELECT bk.*, a.kontonummer FROM bookings bk + JOIN accounts a ON bk.kategorie_konto_id = a.id + WHERE bk.parent_booking_id = ? AND bk.is_reversal = 1', + [$reversalId] + ); + $this->assertEquals(2, count($stornoKinder), 'Storno erzeugt zwei Kindzeilen'); + + $kzNach = $calc->calculate($year, $month); + $delta91n = round((float)$kzNach['91']['steuer'] - (float)$kzPre['91']['steuer'], 2); + $delta67n = round((float)$kzNach['67']['steuer'] - (float)$kzPre['67']['steuer'], 2); + $this->assertEquals(0.00, $delta91n, 'Kz 91 nach Storno wieder auf Ausgangswert'); + $this->assertEquals(0.00, $delta67n, 'Kz 67 nach Storno wieder auf Ausgangswert'); + } +}