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:
commit
6003bab2a8
27 changed files with 1136 additions and 230 deletions
20
Changes.md
20
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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
||||
|
|
|
|||
12
migrations/044_konten_ig_erwerb_ust_vst.sql
Normal file
12
migrations/044_konten_ig_erwerb_ust_vst.sql
Normal 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;
|
||||
78
migrations/045_vorsteuerkonten_buchungszeilen.sql
Normal file
78
migrations/045_vorsteuerkonten_buchungszeilen.sql
Normal 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
|
||||
);
|
||||
54
migrations/046_ust_buchungszeilen.sql
Normal file
54
migrations/046_ust_buchungszeilen.sql
Normal 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
|
||||
);
|
||||
82
migrations/047_ige_rc_buchungszeilen.sql
Normal file
82
migrations/047_ige_rc_buchungszeilen.sql
Normal 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'
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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] ?? [];
|
||||
|
||||
// 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 . ')';
|
||||
|
|
|
|||
124
src/Views/accounts/journal.php
Normal file
124
src/Views/accounts/journal.php
Normal 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>
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
<td class="td-muted text-sm"><?= $h(mb_strimwidth($acc['beschreibung'] ?? '', 0, 60, '…')) ?></td>
|
||||
<td>
|
||||
<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>
|
||||
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
|
||||
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">
|
||||
|
|
|
|||
|
|
@ -212,9 +212,7 @@ $suppliers = $suppliers ?? [];
|
|||
<th>Typ</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Zahlungskonto</th>
|
||||
<th class="td-right">Netto</th>
|
||||
<th class="td-right">USt</th>
|
||||
<th class="td-right">Brutto</th>
|
||||
<th class="td-right">Wert</th>
|
||||
<th>Text</th>
|
||||
<th>Anlage</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['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"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
|
||||
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
|
||||
<td class="text-sm">
|
||||
<?= $h($bk['buchungstext']) ?>
|
||||
<?php if ($isReversal): ?>
|
||||
|
|
@ -292,7 +288,7 @@ $suppliers = $suppliers ?? [];
|
|||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -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): ?>
|
||||
|
|
@ -736,14 +731,11 @@ 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';
|
||||
if (hiddenEl) hiddenEl.value = autoUst;
|
||||
} else {
|
||||
}
|
||||
ustSatzEl.disabled = false;
|
||||
ustSatzEl.title = '';
|
||||
if (hiddenEl) hiddenEl.value = ustSatzEl.value;
|
||||
}
|
||||
if (hiddenEl && autoUst === null) hiddenEl.value = ustSatzEl.value;
|
||||
updateSteuerartVisibility();
|
||||
updateNettoAnzeige();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ',', '.') . ' €'
|
|||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Zusammenfassung</div></div>
|
||||
<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">
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:.35rem 0;color:var(--color-muted)">USt gesamt</td>
|
||||
<td class="td-right td-mono"><?= $fmtKz(($kz['83']['steuer'] ?? 0) + ($kz['84']['steuer'] ?? 0)) ?></td>
|
||||
<td style="padding:.35rem 0;color:var(--color-muted)">USt (Kz 83+84)</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>
|
||||
<td style="padding:.35rem 0;color:var(--color-muted)">Vorsteuer</td>
|
||||
<td class="td-right td-mono" style="color:var(--color-success)">– <?= $fmtKz($kz['66']['steuer'] ?? 0) ?></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($vst66) ?></td>
|
||||
</tr>
|
||||
<tr style="border-top:2px solid #000;font-weight:700">
|
||||
<td style="padding:.5rem 0"><?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td>
|
||||
<td class="td-right td-mono" style="color:<?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>">
|
||||
<?= $fmtKz($kz['69']['steuer'] ?? 0) ?>
|
||||
<td style="padding:.5rem 0"><?= $zahllast >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td>
|
||||
<td class="td-right td-mono" style="color:<?= $zahllast >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>">
|
||||
<?= $fmtKz($zahllast) ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260506
|
||||
1-260510
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,13 +29,15 @@ 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(),
|
||||
|
|
@ -102,30 +104,7 @@ 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();
|
||||
// ── UStVA berechnen und Delta prüfen ──────────────────────────────
|
||||
$kz = $calc->calculate($year, $month);
|
||||
|
||||
// Kz 41: ig. Lieferung 1000 €
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
148
tests/28_VorsteuerkontenTest.php
Normal file
148
tests/28_VorsteuerkontenTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
143
tests/29_UmsatzsteuerkontenTest.php
Normal file
143
tests/29_UmsatzsteuerkontenTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
126
tests/30_IgErwerbReverseChargeTest.php
Normal file
126
tests/30_IgErwerbReverseChargeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue