Feature: USt-Buchungszeilen für Ausgangsrechnungen einführen

- Migration 046: ustva_kz='83'/'84' für Konten 1776/1771, Bestandsmigration für Einnahme-Buchungen
- Booking::create(): USt-Kindzeile auf 1771/1776 für Einnahmen mit 7/19 % (analog VSt)
- UstvaCalculator: Kz 83/84 kontobasiert via 1771/1776, KZ_ACCOUNT_MAP aktualisiert
- Invoice::cancel(): nur Hauptbuchungen direkt stornieren (parent_booking_id IS NULL)
- Tests 10, 27 angepasst; Test 29 neu
This commit is contained in:
fds2610-bot 2026-05-10 15:11:00 +02:00
parent d3a6c379c9
commit 55c02b54de
9 changed files with 251 additions and 29 deletions

View file

@ -2,6 +2,16 @@
--- ---
## 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,8 +55,8 @@ 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 via 1571/1576, Kz 83/84 via 1771/1776)
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. Jede Ausgabe-Buchung mit 7/19 % und `steuerart=normal` erzeugt automatisch eine VSt-Kindzeile auf Konto 1571 (7 %) bzw. 1576 (19 %); jede Einnahme-Buchung entsprechend eine USt-Kindzeile auf 1771 (7 %) bzw. 1776 (19 %). Kz 61/62 und Kz 83/84 werden aus `betrag_netto` dieser Kindzeilen aggregiert. Maßgeblich ist der tatsächliche USt-Satz der Buchung, nicht das Erlöskonto. EU-Buchungen (`steuerart` ∈ {ig_lieferung, export, ig_erwerb_19, ig_erwerb_7}) erhalten keine Steuerkonten-Kindzeilen.
**Quelle 3 – steuerartbasiert** (`bookings.steuerart`) **Quelle 3 – steuerartbasiert** (`bookings.steuerart`)
Auslandssachverhalte unabhängig vom gebuchten Konto: Auslandssachverhalte unabhängig vom gebuchten Konto:
@ -70,9 +70,9 @@ Auslandssachverhalte unabhängig vom gebuchten Konto:
| 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'` |
@ -80,8 +80,8 @@ Auslandssachverhalte unabhängig vom gebuchten Konto:
| **91** | USt auf ig. Erwerb 19 % | § 1a UStG | berechnet | `Kz 89 × 0,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'` | | **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` | | **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` | | **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 | Buchung | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe'` AND `ROUND(ust_satz)=7` | | **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 | abgeleitet | `Kz 91 + Kz 94` | | **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 94) − Kz 66` |
@ -99,8 +99,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,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

@ -133,17 +133,24 @@ class Booking
$mainId = (int)Database::lastInsertId(); $mainId = (int)Database::lastInsertId();
// VSt-Buchungszeile für normale Ausgaben mit 7 % oder 19 % anlegen // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
if ($data['buchungstyp'] === 'Ausgabe' if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true)
&& in_array($ustSatzInt, [7, 19], true) && in_array($ustSatzInt, [7, 19], true)
&& $steuerart === 'normal' && $steuerart === 'normal'
&& abs($ust) > 0.0 && abs($ust) > 0.0
) { ) {
$vstKontonummer = $ustSatzInt === 19 ? '1576' : '1571'; $isAusgabe = $data['buchungstyp'] === 'Ausgabe';
$vstAcc = Account::findByKontonummer($vstKontonummer); if ($isAusgabe) {
if ($vstAcc) { $steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571';
$vstNr = self::generateBuchungsNr(); $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( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id, kategorie_konto_id, zahlungskonto_id,
@ -151,19 +158,19 @@ class Booking
buchungstext, fiscal_year_id) buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$vstNr, $steuerNr,
(int)$data['beleg_id'], (int)$data['beleg_id'],
$mainId, $mainId,
$data['buchungs_datum'], $data['buchungs_datum'],
'Ausgabe', $data['buchungstyp'],
(int)$vstAcc['id'], (int)$steuerAcc['id'],
(int)$data['zahlungskonto_id'], (int)$data['zahlungskonto_id'],
$ust, $ust,
0.0, 0.0,
'normal', 'normal',
0.0, 0.0,
$ust, $ust,
'Vorsteuer zu ' . $nr, $steuerText,
$fy['id'], $fy['id'],
] ]
); );

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,8 +12,10 @@ 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]
@ -44,9 +46,9 @@ class UstvaCalculator
*/ */
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'],
@ -117,8 +119,9 @@ class UstvaCalculator
$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');

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

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