Merge pull request 'Feature: Buchungszeilen-Automatisierung (VSt/USt/ig.Erwerb ReverseCharge)' (#69) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/69
This commit is contained in:
fds2610 2026-05-10 16:45:02 +02:00
commit 6003bab2a8
27 changed files with 1136 additions and 230 deletions

View file

@ -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) ## 2026-05-05 – Feature: Stornorechnung (§ 14 UStG)
### Hinzugefügt ### Hinzugefügt

View file

@ -55,36 +55,42 @@ Diese Berechnung wird ausschließlich für **monatliche** Voranmeldungen unterst
**Quelle 1 – kontobasiert** (`accounts.ustva_kz` des Kategorie-Kontos) **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). 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) **Quelle 2 – Steuerkonten-Buchungszeilen** (Kz 61/62/67/83/84/91 via Steuerkonten)
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. 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`) **Quelle 3 – steuerartbasiert** (`bookings.steuerart`)
Auslandssachverhalte unabhängig vom gebuchten Konto: Auslandssachverhalte unabhängig vom gebuchten Konto:
- `ig_lieferung` → Kz 41 (steuerfreie ig. Lieferung, § 4 Nr. 1b UStG) - `ig_lieferung` → Kz 41 (steuerfreie ig. Lieferung, § 4 Nr. 1b UStG)
- `export` → Kz 44 (steuerfreie Ausfuhr in Drittland, § 4 Nr. 1a UStG) - `export` → Kz 44 (steuerfreie Ausfuhr in Drittland, § 4 Nr. 1a UStG)
- `ig_erwerb_19` → Kz 89 (Bemessungsgrundlage) + Kz 91 (USt = 19 %) - `ig_erwerb_19` → Kz 89 (Bemessungsgrundlage) – Kz 91/67 laufen über Kindzeilen (Quelle 2)
- `ig_erwerb_7` → Kz 93 (Bemessungsgrundlage) + Kz 94 (USt = 7 %)
### Implementierte Kennzahlen ### Implementierte Kennzahlen
| Kz | Bezeichnung (ELSTER) | Materiell-rechtliche Grundlage | Quelle | Berechnung | | 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'` | | **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'` | | **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'` | | **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'` | | **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'` | | **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'` | | **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` | | **91** | USt auf ig. Erwerb 19 % | § 1a UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='91'` (Konto 1774) |
| **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | § 1a UStG, § 12 Abs. 2 UStG | Steuerart | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_7'` | | **61** | Vorsteuerbeträge aus Eingangsrechnungen 19 % | § 15 Abs. 1 Nr. 1 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='61'` (Konto 1576) |
| **94** | USt auf ig. Erwerb 7 % | § 1a UStG | berechnet | `Kz 93 × 0,07` | | **62** | Vorsteuerbeträge aus Eingangsrechnungen 7 % | § 15 Abs. 1 Nr. 1 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='62'` (Konto 1571) |
| **61** | Vorsteuerbeträge aus Eingangsrechnungen 19 % | § 15 Abs. 1 Nr. 1 UStG | Buchung | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe'` AND `ROUND(ust_satz)=19` | | **67** | Vorsteuer aus innergemeinschaftlichem Erwerb | § 15 Abs. 1 Nr. 3 UStG | Steuerkonto | `SUM(betrag_netto)` WHERE `ustva_kz='67'` (Konto 1574) |
| **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` |
| **66** | Vorsteuerbeträge gesamt | § 15 UStG | abgeleitet | `Kz 61 + Kz 62 + Kz 67` | | **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. > **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 | | **35** | (derzeit kein Konto) | Restkategorie |
| **41** | 8125 (falls vorhanden) | Steuerfreie ig. Lieferung | | **41** | 8125 (falls vorhanden) | Steuerfreie ig. Lieferung |
| **44** | 8120 (falls vorhanden) | Ausfuhren | | **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` | | **61** | 1576 (Abziehbare Vorsteuer 19 %) | VSt-Buchungszeilen; Betriebsausgabe-Konten tragen diese Kennzahl **nicht** mehr |
| **62** | alle VSt7-Konten (3108, 3300, 4651, 4656, 4940, …) | wird vom Calculator **ignoriert**, Wert kommt aus `bookings.ust_satz` | | **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". 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".

View file

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

View file

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

View file

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

View file

@ -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'
);

View file

@ -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', fn($p) => (new AccountController())->index($p));
$router->get('/accounts/new', fn($p) => (new AccountController())->create($p)); $router->get('/accounts/new', fn($p) => (new AccountController())->create($p));
$router->post('/accounts', fn($p) => (new AccountController())->store($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->get('/accounts/{id}/edit', fn($p) => (new AccountController())->edit($p));
$router->post('/accounts/{id}', fn($p) => (new AccountController())->update($p)); $router->post('/accounts/{id}', fn($p) => (new AccountController())->update($p));
$router->post('/accounts/{id}/delete', fn($p) => (new AccountController())->destroy($p)); $router->post('/accounts/{id}/delete', fn($p) => (new AccountController())->destroy($p));

View file

@ -198,6 +198,60 @@ class AccountController
View::redirect($back, 'Konto gelöscht.'); 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 public function search(array $params): void
{ {
$q = trim($_GET['q'] ?? ''); $q = trim($_GET['q'] ?? '');

View file

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

View file

@ -19,7 +19,7 @@ class Beleg
OR s.name LIKE ? OR s.name LIKE ?
OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ? OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ?
OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s 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'] . '%'; $s = '%' . $filter['search'] . '%';
$sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%'; $sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%';
$params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]); $params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]);
@ -47,7 +47,7 @@ class Beleg
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$sql = 'SELECT b.*, $sql = 'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk (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, ) AS betrag_brutto,
fy.year as fiscal_year_nr, fy.year as fiscal_year_nr,
s.name as supplier_name, s.name as supplier_name,
@ -77,7 +77,7 @@ class Beleg
return Database::fetchOne( return Database::fetchOne(
'SELECT b.*, 'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk (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, ) AS betrag_brutto,
fy.year as fiscal_year_nr, fy.year as fiscal_year_nr,
s.name as supplier_name, s.name as supplier_name,

View file

@ -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 public static function reverse(int $id, string $reason): int
@ -182,6 +267,40 @@ class Booking
// Link original to reversal // Link original to reversal
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); 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; return $reversalId;
} }

View file

@ -203,7 +203,7 @@ class Invoice
// Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen // Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen
if (!empty($invoice['beleg_id'])) { if (!empty($invoice['beleg_id'])) {
$bookings = Database::fetchAll( $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']] [(int)$invoice['beleg_id']]
); );
foreach ($bookings as $bk) { foreach ($bookings as $bk) {

View file

@ -12,53 +12,50 @@ use Steuerkram\Database;
* Zwei Quellen fließen in die Berechnung ein: * Zwei Quellen fließen in die Berechnung ein:
* *
* 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos): * 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos):
* ustva_kz = '81' → Kz 81 (betrag_netto), Kz 83 (betrag_ust) * ustva_kz = '81' → Kz 81 (betrag_netto)
* ustva_kz = '86' → Kz 86 (betrag_netto), Kz 84 (betrag_ust) * 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 = '35' → Kz 35 (betrag_netto)
* ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto] * ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto]
* ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto] * ustva_kz = '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): * 2. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto):
* 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):
* steuerart = 'ig_lieferung' → Kz 41 (betrag_netto) * steuerart = 'ig_lieferung' → Kz 41 (betrag_netto)
* steuerart = 'export' → Kz 44 (betrag_netto) * steuerart = 'export' → Kz 44 (betrag_netto)
* steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto), Kz 91 (netto × 0,19) * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto, Bemessungsgrundlage)
* steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto), Kz 94 (netto × 0,07)
* Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung * Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung
* ausgeschlossen, um Doppelzählung zu vermeiden. * ausgeschlossen, um Doppelzählung zu vermeiden.
* Buchungen mit EU-Steuerarten fließen ebenfalls NICHT in Kz 61/62.
* *
* Abgeleitete Kennzahlen: * Abgeleitete Kennzahlen:
* Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb)
* Kz 66 = Kz 61 + Kz 62 + Kz 67 (Vorsteuer gesamt) * 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 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']; 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. * 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. * 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 = [ private const KZ_ACCOUNT_MAP = [
'81' => ['81'], '81' => ['81'],
'83' => ['81'], '83' => ['83'],
'86' => ['86'], '86' => ['86'],
'84' => ['86'], '84' => ['84'],
'35' => ['35'], '35' => ['35'],
'41' => ['41'], '41' => ['41'],
'44' => ['44'], '44' => ['44'],
'61' => ['61'],
'62' => ['62'],
'67' => ['67'],
'91' => ['91'],
]; ];
/** /**
@ -68,17 +65,13 @@ class UstvaCalculator
'41' => ['ig_lieferung'], '41' => ['ig_lieferung'],
'44' => ['export'], '44' => ['export'],
'89' => ['ig_erwerb_19'], '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'], '69' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'],
]; ];
public function calculate(int $year, int $month): array 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), '?')); $euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$accountRows = Database::fetchAll( $accountRows = Database::fetchAll(
'SELECT a.ustva_kz, 'SELECT a.ustva_kz,
@ -89,7 +82,6 @@ class UstvaCalculator
JOIN accounts a ON a.id = bk.kategorie_konto_id JOIN accounts a ON a.id = bk.kategorie_konto_id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND a.ustva_kz IS NOT NULL AND a.ustva_kz != \'\' 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 . ')) AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY a.ustva_kz', GROUP BY a.ustva_kz',
array_merge([$year, $month], self::EU_STEUERARTEN) array_merge([$year, $month], self::EU_STEUERARTEN)
@ -103,28 +95,7 @@ class UstvaCalculator
]; ];
} }
// ── 2. Buchungsbasierte Vorsteuer Kz 61/62 (via ust_satz) ──────────── // ── 2. Steuerart-basierte Kennzahlen ─────────────────────────────────
$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 ─────────────────────────────────
$stRows = Database::fetchAll( $stRows = Database::fetchAll(
'SELECT bk.steuerart, SUM(bk.betrag_netto) AS summe_netto 'SELECT bk.steuerart, SUM(bk.betrag_netto) AS summe_netto
FROM bookings bk FROM bookings bk
@ -140,25 +111,25 @@ class UstvaCalculator
$bySteuerart[$row['steuerart']] = (float)$row['summe_netto']; $bySteuerart[$row['steuerart']] = (float)$row['summe_netto'];
} }
// ── 4. Kennzahlen zusammensetzen ───────────────────────────────────── // ── 3. Kennzahlen zusammensetzen ─────────────────────────────────────
$n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0); $n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0);
$u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0); $u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0);
$st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0); $st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0);
$kz81 = $n('81'); $kz83 = $u('81'); // Kz 83/84 kontobasiert: betrag_netto der USt-Buchungszeilen (1776/1771)
$kz86 = $n('86'); $kz84 = $u('86'); $kz81 = $n('81'); $kz83 = $n('83');
$kz86 = $n('86'); $kz84 = $n('84');
$kz35 = $n('35'); $kz35 = $n('35');
$kz41 = $n('41') + $st('ig_lieferung'); $kz41 = $n('41') + $st('ig_lieferung');
$kz44 = $n('44') + $st('export'); $kz44 = $n('44') + $st('export');
$kz89 = $st('ig_erwerb_19'); $kz89 = $st('ig_erwerb_19');
$kz91 = round($kz89 * 0.19, 2); // Kz 61/62/67/91 kontobasiert via Steuerkonten (1576/1571/1574/1774)
$kz93 = $st('ig_erwerb_7'); $kz61 = $n('61');
$kz94 = round($kz93 * 0.07, 2); $kz62 = $n('62');
$kz61 = $u('61'); $kz67 = $n('67');
$kz62 = $u('62'); $kz91 = $n('91');
$kz67 = $kz91 + $kz94;
$kz66 = $kz61 + $kz62 + $kz67; $kz66 = $kz61 + $kz62 + $kz67;
$kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66; $kz69 = ($kz83 + $kz84 + $kz91) - $kz66;
return [ return [
'81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], '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], '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], '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)], '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)], '61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => round($kz61, 2)],
'62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => round($kz62, 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)], '66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => round($kz66, 2)],
'69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => round($kz69, 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). * 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 public function fetchBookingsForKz(int $year, int $month, string $kz): array
{ {
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? []; $accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? []; $steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
// Kz 61/62: buchungsbasiert via ust_satz if (empty($accountKzList) && empty($steuerartList)) {
$vstSatz = array_search($kz, self::VST_SATZ_KZ, true);
if (empty($accountKzList) && empty($steuerartList) && $vstSatz === false) {
return []; 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)) { if (!empty($steuerartList)) {
$ph = implode(',', array_fill(0, count($steuerartList), '?')); $ph = implode(',', array_fill(0, count($steuerartList), '?'));
$parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')'; $parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')';

View file

@ -0,0 +1,124 @@
<?php
use Steuerkram\View;
$h = fn($v) => 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);
?>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">
<?= $h($account['kontonummer']) ?> – <?= $h($account['bezeichnung']) ?>
</div>
<div class="text-muted text-sm"><?= $h($account['kontenart']) ?></div>
</div>
<div style="display:flex;gap:.5rem;align-items:center">
<?php if ($hasPrev): ?>
<a href="/accounts/<?= $accountId ?>/journal?year=<?= $prevYear ?>" class="btn btn-secondary btn-sm">← <?= $prevYear ?></a>
<?php else: ?>
<button class="btn btn-secondary btn-sm" disabled>←</button>
<?php endif; ?>
<span class="font-bold" style="min-width:4rem;text-align:center"><?= $year ?></span>
<?php if ($hasNext): ?>
<a href="/accounts/<?= $accountId ?>/journal?year=<?= $nextYear ?>" class="btn btn-secondary btn-sm"><?= $nextYear ?> →</a>
<?php else: ?>
<button class="btn btn-secondary btn-sm" disabled>→</button>
<?php endif; ?>
<a href="/accounts" class="btn btn-secondary btn-sm">← Kontenrahmen</a>
</div>
</div>
<?php if (empty($bookings)): ?>
<div class="card-body">
<p class="text-muted">Keine Buchungen für dieses Konto in <?= $year ?>.</p>
</div>
<?php else: ?>
<?php
$saldo = 0.0;
foreach ($bookings as $b) {
$isKategorie = (int)$b['kategorie_konto_id'] === $accountId;
if ($isKategorie) {
$wert = (float)$b['betrag_netto'];
} else {
// Zahlungs-Seite: Brutto-Betrag, Ausgaben fließen ab
$wert = (float)$b['betrag_brutto'];
if ($b['buchungstyp'] === 'Ausgabe') {
$wert = -$wert;
}
}
$saldo += $wert;
}
?>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Nummer</th>
<th>Beleg</th>
<th>Typ</th>
<th>Gegenkonto</th>
<th>Text</th>
<th class="td-right">Wert</th>
</tr>
</thead>
<tbody>
<?php
$laufendSaldo = 0.0;
foreach ($bookings as $b):
$isKategorie = (int)$b['kategorie_konto_id'] === $accountId;
if ($isKategorie) {
$wert = (float)$b['betrag_netto'];
$gegenkonto = $b['zahlungs_nr'] . ' ' . $b['zahlungs_name'];
} else {
$wert = (float)$b['betrag_brutto'];
if ($b['buchungstyp'] === 'Ausgabe') {
$wert = -$wert;
}
$gegenkonto = $b['kategorie_nr'] . ' ' . $b['kategorie_name'];
}
$laufendSaldo += $wert;
$isReversal = (bool)$b['is_reversal'];
?>
<tr<?= $isReversal ? ' style="font-style:italic;color:#888"' : '' ?>>
<td class="td-mono text-sm"><?= $h($b['buchungs_datum']) ?></td>
<td class="td-mono text-sm"><?= $h($b['buchungs_nr']) ?></td>
<td>
<?php if ($b['beleg_id']): ?>
<a href="/belege/<?= (int)$b['beleg_id'] ?>"><?= $h($b['beleg_nr'] ?? '#' . $b['beleg_id']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $b['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>">
<?= $h($b['buchungstyp']) ?>
</span>
</td>
<td class="td-mono text-sm"><?= $h($gegenkonto) ?></td>
<td><?= $h($b['buchungstext']) ?></td>
<td class="td-right td-mono<?= $wert < 0 ? ' text-danger' : '' ?>">
<?= $wert < 0 ? '−' : '' ?><?= $fmt($wert) ?> €
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;border-top:2px solid var(--border-color,#e5e7eb)">
<td colspan="6">Kontostand <?= $year ?> (<?= count($bookings) ?> Buchungen)</td>
<td class="td-right td-mono<?= $saldo < 0 ? ' text-danger' : '' ?>">
<?= $saldo < 0 ? '−' : '' ?><?= $fmt($saldo) ?> €
</td>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>

View file

@ -61,6 +61,7 @@
<td class="td-muted text-sm"><?= $h(mb_strimwidth($acc['beschreibung'] ?? '', 0, 60, '…')) ?></td> <td class="td-muted text-sm"><?= $h(mb_strimwidth($acc['beschreibung'] ?? '', 0, 60, '…')) ?></td>
<td> <td>
<div style="display:flex;gap:.5rem"> <div style="display:flex;gap:.5rem">
<a href="/accounts/<?= $h($acc['id']) ?>/journal" class="btn btn-secondary btn-sm">Journal</a>
<a href="/accounts/<?= $h($acc['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a> <a href="/accounts/<?= $h($acc['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete" <form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?"> data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">

View file

@ -212,9 +212,7 @@ $suppliers = $suppliers ?? [];
<th>Typ</th> <th>Typ</th>
<th>Kategorie</th> <th>Kategorie</th>
<th>Zahlungskonto</th> <th>Zahlungskonto</th>
<th class="td-right">Netto</th> <th class="td-right">Wert</th>
<th class="td-right">USt</th>
<th class="td-right">Brutto</th>
<th>Text</th> <th>Text</th>
<th>Anlage</th> <th>Anlage</th>
<th></th> <th></th>
@ -232,8 +230,6 @@ $suppliers = $suppliers ?? [];
<td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td> <td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td> <td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td> <td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
<td class="text-sm"> <td class="text-sm">
<?= $h($bk['buchungstext']) ?> <?= $h($bk['buchungstext']) ?>
<?php if ($isReversal): ?> <?php if ($isReversal): ?>
@ -292,7 +288,7 @@ $suppliers = $suppliers ?? [];
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($bookings)): ?> <?php if (empty($bookings)): ?>
<tr><td colspan="11" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr> <tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -387,8 +383,7 @@ $suppliers = $suppliers ?? [];
'normal' => 'Normal (Inland)', 'normal' => 'Normal (Inland)',
'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)', 'ig_lieferung' => 'Steuerfreie ig. Lieferung § 4 Nr. 1b (Kz 41)',
'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)', 'export' => 'Steuerfreie Ausfuhr § 4 Nr. 1a (Kz 44)',
'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % (Kz 89/91)', 'ig_erwerb_19' => 'Innergemeinschaftlicher Erwerb 19 % – ReverseCharge (Kz 89/91/67)',
'ig_erwerb_7' => 'Innergemeinschaftlicher Erwerb 7 % (Kz 93/94)',
]; ];
$currentSteuerart = $formData['steuerart'] ?? 'normal'; $currentSteuerart = $formData['steuerart'] ?? 'normal';
foreach ($steuerartOptionen as $val => $label): ?> foreach ($steuerartOptionen as $val => $label): ?>
@ -736,14 +731,11 @@ function updateAutoUst() {
const hiddenEl = document.getElementById('ust_satz_hidden'); const hiddenEl = document.getElementById('ust_satz_hidden');
if (autoUst !== null) { if (autoUst !== null) {
ustSatzEl.value = autoUst; ustSatzEl.value = autoUst;
ustSatzEl.disabled = true;
ustSatzEl.title = 'Automatisch durch Steuercode des Kontos gesetzt';
if (hiddenEl) hiddenEl.value = autoUst; if (hiddenEl) hiddenEl.value = autoUst;
} else { }
ustSatzEl.disabled = false; ustSatzEl.disabled = false;
ustSatzEl.title = ''; ustSatzEl.title = '';
if (hiddenEl) hiddenEl.value = ustSatzEl.value; if (hiddenEl && autoUst === null) hiddenEl.value = ustSatzEl.value;
}
updateSteuerartVisibility(); updateSteuerartVisibility();
updateNettoAnzeige(); updateNettoAnzeige();
} }

View file

@ -32,7 +32,7 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
$sections = [ $sections = [
'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'], 'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'],
'Umsatzsteuer auf Umsätze' => ['83', '84'], 'Umsatzsteuer auf Umsätze' => ['83', '84'],
'Innergemeinschaftlicher Erwerb' => ['89', '91', '93', '94'], 'Innergemeinschaftlicher Erwerb' => ['89', '91'],
'Vorsteuerbeträge' => ['61', '62', '67', '66'], 'Vorsteuerbeträge' => ['61', '62', '67', '66'],
'Verbleibende Zahllast' => ['69'], 'Verbleibende Zahllast' => ['69'],
]; ];
@ -98,6 +98,14 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
<div class="card mb-2"> <div class="card mb-2">
<div class="card-header"><div class="card-title">Zusammenfassung</div></div> <div class="card-header"><div class="card-title">Zusammenfassung</div></div>
<div class="card-body"> <div class="card-body">
<?php
$ust83 = (float)($kz['83']['steuer'] ?? 0);
$ust84 = (float)($kz['84']['steuer'] ?? 0);
$ust91 = (float)($kz['91']['steuer'] ?? 0);
$ustGes = $ust83 + $ust84 + $ust91;
$vst66 = (float)($kz['66']['steuer'] ?? 0);
$zahllast = (float)($kz['69']['steuer'] ?? 0);
?>
<table style="width:100%;font-size:.9rem"> <table style="width:100%;font-size:.9rem">
<tr> <tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 19 %</td> <td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 19 %</td>
@ -108,17 +116,27 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €'
<td class="td-right td-mono"><?= $fmtKz($kz['86']['betrag']) ?></td> <td class="td-right td-mono"><?= $fmtKz($kz['86']['betrag']) ?></td>
</tr> </tr>
<tr> <tr>
<td style="padding:.35rem 0;color:var(--color-muted)">USt gesamt</td> <td style="padding:.35rem 0;color:var(--color-muted)">USt (Kz 83+84)</td>
<td class="td-right td-mono"><?= $fmtKz(($kz['83']['steuer'] ?? 0) + ($kz['84']['steuer'] ?? 0)) ?></td> <td class="td-right td-mono"><?= $fmtKz($ust83 + $ust84) ?></td>
</tr>
<?php if ($ust91 != 0.0): ?>
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">ig. Erwerb USt (Kz 91)</td>
<td class="td-right td-mono"><?= $fmtKz($ust91) ?></td>
</tr>
<?php endif; ?>
<tr style="border-top:1px solid #e2e8f0">
<td style="padding:.35rem 0;font-weight:600">USt gesamt</td>
<td class="td-right td-mono" style="font-weight:600"><?= $fmtKz($ustGes) ?></td>
</tr> </tr>
<tr> <tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Vorsteuer</td> <td style="padding:.35rem 0;color:var(--color-muted)">Vorsteuer (Kz 66)</td>
<td class="td-right td-mono" style="color:var(--color-success)">– <?= $fmtKz($kz['66']['steuer'] ?? 0) ?></td> <td class="td-right td-mono" style="color:var(--color-success)">– <?= $fmtKz($vst66) ?></td>
</tr> </tr>
<tr style="border-top:2px solid #000;font-weight:700"> <tr style="border-top:2px solid #000;font-weight:700">
<td style="padding:.5rem 0"><?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td> <td style="padding:.5rem 0"><?= $zahllast >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td>
<td class="td-right td-mono" style="color:<?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>"> <td class="td-right td-mono" style="color:<?= $zahllast >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>">
<?= $fmtKz($kz['69']['steuer'] ?? 0) ?> <?= $fmtKz($zahllast) ?>
</td> </td>
</tr> </tr>
</table> </table>

View file

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

View file

@ -129,9 +129,10 @@ class TaxSummenTest extends TestCase
'UStVA Kz 66 (Vorsteuer gesamt): ≥ Kz 61' '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( $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 2
); );
$this->assertEquals($erwKz69, (float)$kz['69']['steuer'], 'UStVA Kz 69 (Zahllast) korrekt berechnet'); $this->assertEquals($erwKz69, (float)$kz['69']['steuer'], 'UStVA Kz 69 (Zahllast) korrekt berechnet');

View file

@ -6,7 +6,7 @@ declare(strict_types=1);
* Test 7: UStVA-Kennzahlen für Auslandsumsätze und ig. Erwerb. * Test 7: UStVA-Kennzahlen für Auslandsumsätze und ig. Erwerb.
* *
* Prüft, dass steuerart-Buchungen korrekt in die UStVA-Kennzahlen * 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 class UstvaAuslandTest extends TestCase
{ {
@ -29,13 +29,15 @@ class UstvaAuslandTest extends TestCase
} }
$this->assert(true, 'Konten vorhanden'); $this->assert(true, 'Konten vorhanden');
// Zweites Zahlungskonto für Ausgabe-Buchungen
$bankId = \Steuerkram\Database::fetchOne( $bankId = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 AND id != ? LIMIT 1", "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 AND id != ? LIMIT 1",
[$zahlungId] [$zahlungId]
); );
$bankId = $bankId ? (int)$bankId['id'] : $zahlungId; $bankId = $bankId ? (int)$bankId['id'] : $zahlungId;
$calc = new \Steuerkram\Services\UstvaCalculator();
$kzPre = $calc->calculate($year, $month);
// ── 1. Steuerfreie ig. Lieferung → Kz 41 ────────────────────────── // ── 1. Steuerfreie ig. Lieferung → Kz 41 ──────────────────────────
$belegIgL = \Steuerkram\Models\Beleg::create([ $belegIgL = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-IGL-' . uniqid(), 'beleg_nr' => 'TEST-IGL-' . uniqid(),
@ -102,30 +104,7 @@ class UstvaAuslandTest extends TestCase
'buchungstext' => 'Test ig. Erwerb 19%', 'buchungstext' => 'Test ig. Erwerb 19%',
]); ]);
// ── 4. Ig. Erwerb 7 % → Kz 93/94/67 ────────────────────────────── // ── UStVA berechnen und Delta prüfen ──────────────────────────────
$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); $kz = $calc->calculate($year, $month);
// Kz 41: ig. Lieferung 1000 € // Kz 41: ig. Lieferung 1000 €
@ -140,58 +119,38 @@ class UstvaAuslandTest extends TestCase
'Kz 44 (Ausfuhr): Betrag ≥ 2.000 €' 'Kz 44 (Ausfuhr): Betrag ≥ 2.000 €'
); );
// Kz 89: ig. Erwerb 19% Bemessungsgrundlage 500 € // Kz 89: ig. Erwerb 19% Bemessungsgrundlage +500 €
$this->assert( $this->assert(
(float)$kz['89']['betrag'] >= 500.00, round((float)$kz['89']['betrag'] - (float)$kzPre['89']['betrag'], 2) >= 500.00,
'Kz 89 (ig. Erwerb 19% Bmgl.): Betrag ≥ 500 €' '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( $this->assertEquals(
round(500.00 * 0.19, 2), 95.00,
(float)$kz['91']['steuer'], round((float)$kz['91']['steuer'] - (float)$kzPre['91']['steuer'], 2),
'Kz 91 (USt ig. Erwerb 19%): Steuer = 95,00 €' 'Kz 91 (USt ig. Erwerb 19%): Delta = 95,00 €'
); );
// Kz 93: ig. Erwerb 7% Bemessungsgrundlage 300 € // Kz 67: VSt ig. Erwerb kontobasiert (1574) +95 €
$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 €
$this->assertEquals( $this->assertEquals(
round(300.00 * 0.07, 2), 95.00,
(float)$kz['94']['steuer'], round((float)$kz['67']['steuer'] - (float)$kzPre['67']['steuer'], 2),
'Kz 94 (USt ig. Erwerb 7%): Steuer = 21,00 €' 'Kz 67 (VSt ig. Erwerb): Delta = 95,00 €'
); );
// Kz 67: Vorsteuer aus ig. Erwerb = Kz 91 + Kz 94 = 116 € // ReverseCharge ist zahllastneutral: Kz 69 unverändert
$erwartetKz67 = round((float)$kz['91']['steuer'] + (float)$kz['94']['steuer'], 2);
$this->assertEquals( $this->assertEquals(
$erwartetKz67, 0.00,
(float)$kz['67']['steuer'], round((float)$kz['69']['steuer'] - (float)$kzPre['69']['steuer'], 2),
'Kz 67 (Vorsteuer ig. Erwerb) = Kz 91 + Kz 94' 'Kz 69 (Zahllast) durch ig. Erwerb nicht verändert'
); );
// Kz 69 Plausibilitätsprüfung: ig. Erwerb neutralisiert sich durch Kz 67 // ig. Erwerb darf nicht in Kz 35 landen
// 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
$this->assertEquals( $this->assertEquals(
0.0, 0.0,
(float)$kz['35']['betrag'], (float)$kz['35']['betrag'],
'Kz 35 (steuerfreie Inlandsumsätze) = 0 (kein normaler 0%-Umsatz gebucht)' 'Kz 35 (steuerfreie Inlandsumsätze) = 0'
); );
} }

View file

@ -100,8 +100,11 @@ class IstVersteuerungTest extends TestCase
$this->assertGreaterThan(0, $buchungId, 'Buchung mit Zahldatum erfasst'); $this->assertGreaterThan(0, $buchungId, 'Buchung mit Zahldatum erfasst');
// Buchung hat das Zahldatum – nicht das Rechnungsdatum // Buchung hat das Zahldatum – nicht das Rechnungsdatum
$buchungenNachZahlung = \Steuerkram\Models\Booking::forBeleg($belegId); $buchungenNachZahlung = array_values(array_filter(
$this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Buchung vorhanden'); \Steuerkram\Models\Booking::forBeleg($belegId),
fn($b) => $b['parent_booking_id'] === null
));
$this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Hauptbuchung vorhanden');
$bk = $buchungenNachZahlung[0]; $bk = $buchungenNachZahlung[0];
$this->assertEquals($zahlDatum, $bk['buchungs_datum'], 'Buchungsdatum ist das Zahldatum'); $this->assertEquals($zahlDatum, $bk['buchungs_datum'], 'Buchungsdatum ist das Zahldatum');

View file

@ -55,7 +55,8 @@ class BelegGebuechtStatusTest extends TestCase
$beleg = Beleg::find($belegId); $beleg = Beleg::find($belegId);
$this->assertEquals(1, (int)($beleg['booked'] ?? 0), 'Nach Buchung: booked = 1 (abgeleitet)'); $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]); Database::execute('DELETE FROM bookings WHERE id = ?', [$buchungId]);
// Nach Löschen der Buchung: booked = 0 // Nach Löschen der Buchung: booked = 0

View file

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

View file

@ -146,10 +146,10 @@ class StornorechnungTest extends TestCase
// ── 5. Stornobuchung muss am Originalbeleg hängen ───────────── // ── 5. Stornobuchung muss am Originalbeleg hängen ─────────────
$reversalRows = \Steuerkram\Database::fetchAll( $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] [$belegId]
); );
$this->assertEquals(1, count($reversalRows), 'Genau eine Stornobuchung am Originalbeleg'); $this->assertEquals(1, count($reversalRows), 'Genau eine Hauptstornobuchung am Originalbeleg');
$reversal = $reversalRows[0]; $reversal = $reversalRows[0];
$this->assertEquals(-250.00, round((float)$reversal['betrag_netto'], 2), 'Stornobuchung Netto = -250'); $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'); $this->assertEquals(-47.50, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung USt = -47,50');

View file

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

View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
/**
* Test 29: USt-Buchungszeilen für Ausgangsrechnungen.
*
* Prüft, dass Einnahme-Buchungen mit USt 7 % / 19 % automatisch eine
* USt-Buchungszeile auf 1771 / 1776 erhalten, die UStVA Kz 83/84 daraus
* kontobasiert ableitet und ein Storno die USt-Zeilen mitaufhebt.
*/
class UmsatzsteuerkontenTest extends TestCase
{
protected string $testName = 'Test 29: USt-Buchungszeilen für Ausgangsrechnungen';
public function run(): void
{
$year = (int)date('Y');
$month = (int)date('n');
$datum = date('Y-m-d');
$zahlungsId = $this->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');
}
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/**
* Test 30: Innergemeinschaftlicher Erwerb 19 % – ReverseCharge-Kindzeilen.
*
* Prüft, dass ig_erwerb_19-Buchungen automatisch zwei Kindzeilen erhalten
* (1574 für Kz 67, 1774 für Kz 91) und die UStVA diese kontobasiert ausweist.
* Storno muss beide Kindzeilen mitaufheben.
*/
class IgErwerbReverseChargeTest extends TestCase
{
protected string $testName = 'Test 30: ig. Erwerb 19 % ReverseCharge-Kindzeilen';
public function run(): void
{
$year = (int)date('Y');
$month = (int)date('n');
$datum = date('Y-m-d');
$zahlungsId = $this->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');
}
}