Fix #5: Buchungsmodell von Soll/Haben auf reines EÜR-System umgestellt

Schema: bookings mit buchungstyp (Einnahme/Ausgabe), kategorie_konto_id,
zahlungskonto_id statt soll_konto_id/haben_konto_id. Kontenarten umbenannt
(Ertrag→Betriebseinnahme, Aufwand→Betriebsausgabe, Aktiv→Zahlungskonto,
Passiv→Verbindlichkeit, Neutral→Privat). UI, Services, Controller, Tests
vollständig auf EÜR-Terminologie umgestellt. Alle 78 Tests bestanden.
This commit is contained in:
fds2610 2026-03-26 10:53:59 +01:00
parent 632cbeea12
commit fb579c585c
19 changed files with 536 additions and 343 deletions

View file

@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS accounts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kontonummer VARCHAR(10) NOT NULL UNIQUE, kontonummer VARCHAR(10) NOT NULL UNIQUE,
bezeichnung VARCHAR(255) NOT NULL, bezeichnung VARCHAR(255) NOT NULL,
kontenart ENUM('Aktiv','Passiv','Aufwand','Ertrag','Neutral') NOT NULL, kontenart ENUM('Betriebseinnahme','Betriebsausgabe','Zahlungskonto','Verbindlichkeit','Privat') NOT NULL,
beschreibung TEXT, beschreibung TEXT,
steuercode VARCHAR(20) NULL COMMENT 'z.B. USt19, VSt19, USt7, VSt7', steuercode VARCHAR(20) NULL COMMENT 'z.B. USt19, VSt19, USt7, VSt7',
ustva_kz VARCHAR(10) NULL COMMENT 'ELSTER Kennzahl z.B. 81, 35', ustva_kz VARCHAR(10) NULL COMMENT 'ELSTER Kennzahl z.B. 81, 35',
@ -122,34 +122,36 @@ CREATE TABLE IF NOT EXISTS belege (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================ -- ============================================================
-- Buchungen (Journaleinträge, doppelte Buchführung) -- Buchungen (EÜR-Journaleinträge: Einnahme oder Ausgabe)
-- GoBD: KEINE UPDATE/DELETE auf gesperrte Buchungen -- GoBD: KEINE UPDATE/DELETE auf gesperrte Buchungen
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS bookings ( CREATE TABLE IF NOT EXISTS bookings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
buchungs_nr VARCHAR(30) NOT NULL UNIQUE, buchungs_nr VARCHAR(30) NOT NULL UNIQUE,
beleg_id INT UNSIGNED NOT NULL, beleg_id INT UNSIGNED NOT NULL,
buchungs_datum DATE NOT NULL, buchungs_datum DATE NOT NULL,
soll_konto_id INT UNSIGNED NOT NULL, buchungstyp ENUM('Einnahme','Ausgabe') NOT NULL,
haben_konto_id INT UNSIGNED NOT NULL, kategorie_konto_id INT UNSIGNED NOT NULL COMMENT 'Was? z.B. Büromaterial, Honorar',
betrag_netto DECIMAL(12,2) NOT NULL, zahlungskonto_id INT UNSIGNED NOT NULL COMMENT 'Wie bezahlt? z.B. Bank, Kasse',
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'Steuersatz in % (0, 7, 19)', betrag_netto DECIMAL(12,2) NOT NULL,
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00, ust_satz DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'Steuersatz in % (0, 7, 19)',
betrag_brutto DECIMAL(12,2) NOT NULL, betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
buchungstext VARCHAR(500) NOT NULL, betrag_brutto DECIMAL(12,2) NOT NULL,
is_reversal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = Stornobuchung', buchungstext VARCHAR(500) NOT NULL,
reversed_by INT UNSIGNED NULL COMMENT 'ID der Stornobuchung', is_reversal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = Stornobuchung',
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: unveränderlich', reversed_by INT UNSIGNED NULL COMMENT 'ID der Stornobuchung',
fiscal_year_id INT UNSIGNED NOT NULL, locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: unveränderlich',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, fiscal_year_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_booking_beleg FOREIGN KEY (beleg_id) REFERENCES belege(id), CONSTRAINT fk_booking_beleg FOREIGN KEY (beleg_id) REFERENCES belege(id),
CONSTRAINT fk_booking_soll FOREIGN KEY (soll_konto_id) REFERENCES accounts(id), CONSTRAINT fk_booking_kategorie FOREIGN KEY (kategorie_konto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_haben FOREIGN KEY (haben_konto_id) REFERENCES accounts(id), CONSTRAINT fk_booking_zahlung FOREIGN KEY (zahlungskonto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_year FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_years(id), CONSTRAINT fk_booking_year FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_years(id),
INDEX idx_buchungs_datum (buchungs_datum), INDEX idx_buchungs_datum (buchungs_datum),
INDEX idx_fiscal_year (fiscal_year_id), INDEX idx_fiscal_year (fiscal_year_id),
INDEX idx_soll_konto (soll_konto_id), INDEX idx_buchungstyp (buchungstyp),
INDEX idx_haben_konto (haben_konto_id) INDEX idx_kategorie_konto (kategorie_konto_id),
INDEX idx_zahlungskonto (zahlungskonto_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- GoBD-Trigger: Gesperrte Buchungen dürfen nicht geändert werden -- GoBD-Trigger: Gesperrte Buchungen dürfen nicht geändert werden

View file

@ -1,5 +1,5 @@
-- SKR03 Kontenrahmen - Wichtigste Konten für EÜR / Freiberufler -- SKR03 Kontenrahmen - Wichtigste Konten für EÜR / Freiberufler
-- Kontenarten: Aktiv, Passiv, Aufwand, Ertrag, Neutral -- Kontenarten: Betriebseinnahme, Betriebsausgabe, Zahlungskonto, Verbindlichkeit, Privat
-- steuercode: USt19, USt7, VSt19, VSt7, NULL (kein Steuercode) -- steuercode: USt19, USt7, VSt19, VSt7, NULL (kein Steuercode)
-- ustva_kz: ELSTER-Kennzahl für UStVA -- ustva_kz: ELSTER-Kennzahl für UStVA
@ -8,116 +8,116 @@ INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz,
-- ============================================================ -- ============================================================
-- KLASSE 0: Anlagevermögen -- KLASSE 0: Anlagevermögen
-- ============================================================ -- ============================================================
('0410', 'EDV-Software', 'Aktiv', NULL, NULL, 1), ('0410', 'EDV-Software', 'Zahlungskonto', NULL, NULL, 1),
('0420', 'Büroeinrichtung', 'Aktiv', NULL, NULL, 1), ('0420', 'Büroeinrichtung', 'Zahlungskonto', NULL, NULL, 1),
('0430', 'Technische Anlagen und Maschinen', 'Aktiv', NULL, NULL, 1), ('0430', 'Technische Anlagen und Maschinen', 'Zahlungskonto', NULL, NULL, 1),
('0480', 'Betriebs- und Geschäftsausstattung', 'Aktiv', NULL, NULL, 1), ('0480', 'Betriebs- und Geschäftsausstattung', 'Zahlungskonto', NULL, NULL, 1),
('0490', 'Geringwertige Wirtschaftsgüter', 'Aktiv', NULL, NULL, 1), ('0490', 'Geringwertige Wirtschaftsgüter', 'Zahlungskonto', NULL, NULL, 1),
-- ============================================================ -- ============================================================
-- KLASSE 1: Umlaufvermögen / Geldkonten -- KLASSE 1: Umlaufvermögen / Geldkonten
-- ============================================================ -- ============================================================
('1000', 'Kasse', 'Aktiv', NULL, NULL, 1), ('1000', 'Kasse', 'Zahlungskonto', NULL, NULL, 1),
('1200', 'Bank', 'Aktiv', NULL, NULL, 1), ('1200', 'Bank', 'Zahlungskonto', NULL, NULL, 1),
('1361', 'Abziehbare Vorsteuer 19 %', 'Aktiv', 'VSt19', '66', 1), ('1361', 'Abziehbare Vorsteuer 19 %', 'Zahlungskonto', 'VSt19', '66', 1),
('1371', 'Abziehbare Vorsteuer 7 %', 'Aktiv', 'VSt7', '66', 1), ('1371', 'Abziehbare Vorsteuer 7 %', 'Zahlungskonto', 'VSt7', '66', 1),
('1400', 'Forderungen aus Lieferungen und Leistungen', 'Aktiv', NULL, NULL, 1), ('1400', 'Forderungen aus Lieferungen und Leistungen', 'Zahlungskonto', NULL, NULL, 1),
('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Aktiv', NULL, NULL, 1), ('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Zahlungskonto', NULL, NULL, 1),
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Aktiv', 'VSt19', '66', 1), ('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Zahlungskonto', 'VSt19', '66', 1),
('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Aktiv', 'VSt7', '66', 1), ('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Zahlungskonto', 'VSt7', '66', 1),
-- ============================================================ -- ============================================================
-- KLASSE 1: Verbindlichkeiten (Passiv) -- KLASSE 1: Verbindlichkeiten (Passiv)
-- ============================================================ -- ============================================================
('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Passiv', NULL, NULL, 1), ('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Verbindlichkeit', NULL, NULL, 1),
('1700', 'Sonstige Verbindlichkeiten', 'Passiv', NULL, NULL, 1), ('1700', 'Sonstige Verbindlichkeiten', 'Verbindlichkeit', NULL, NULL, 1),
('1741', 'Umsatzsteuer 19 %', 'Passiv', 'USt19', '83', 1), ('1741', 'Umsatzsteuer 19 %', 'Verbindlichkeit', 'USt19', '83', 1),
('1742', 'Umsatzsteuer 7 %', 'Passiv', 'USt7', '84', 1), ('1742', 'Umsatzsteuer 7 %', 'Verbindlichkeit', 'USt7', '84', 1),
('1771', 'Umsatzsteuer-Vorauszahlungen', 'Passiv', NULL, NULL, 1), ('1771', 'Umsatzsteuer-Vorauszahlungen', 'Verbindlichkeit', NULL, NULL, 1),
('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Passiv', NULL, NULL, 1), ('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Verbindlichkeit', NULL, NULL, 1),
-- ============================================================ -- ============================================================
-- KLASSE 3: Wareneinkauf (Aufwand) -- KLASSE 3: Wareneinkauf (Aufwand)
-- ============================================================ -- ============================================================
('3100', 'Wareneinkauf 19 % Vorsteuer', 'Aufwand', 'VSt19', '66', 1), ('3100', 'Wareneinkauf 19 % Vorsteuer', 'Betriebsausgabe', 'VSt19', '66', 1),
('3110', 'Wareneinkauf 7 % Vorsteuer', 'Aufwand', 'VSt7', '66', 1), ('3110', 'Wareneinkauf 7 % Vorsteuer', 'Betriebsausgabe', 'VSt7', '66', 1),
('3120', 'Wareneinkauf ohne Vorsteuer', 'Aufwand', NULL, NULL, 1), ('3120', 'Wareneinkauf ohne Vorsteuer', 'Betriebsausgabe', NULL, NULL, 1),
-- ============================================================ -- ============================================================
-- KLASSE 4: Betriebliche Aufwendungen -- KLASSE 4: Betriebliche Aufwendungen
-- ============================================================ -- ============================================================
('4100', 'Gehälter', 'Aufwand', NULL, NULL, 1), ('4100', 'Gehälter', 'Betriebsausgabe', NULL, NULL, 1),
('4110', 'Löhne', 'Aufwand', NULL, NULL, 1), ('4110', 'Löhne', 'Betriebsausgabe', NULL, NULL, 1),
('4120', 'Gesetzliche soziale Aufwendungen', 'Aufwand', NULL, NULL, 1), ('4120', 'Gesetzliche soziale Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4130', 'Freiwillige soziale Aufwendungen', 'Aufwand', NULL, NULL, 1), ('4130', 'Freiwillige soziale Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4140', 'Aufwendungen für Altersversorgung', 'Aufwand', NULL, NULL, 1), ('4140', 'Aufwendungen für Altersversorgung', 'Betriebsausgabe', NULL, NULL, 1),
('4200', 'Raumkosten (Miete)', 'Aufwand', 'VSt19', '66', 1), ('4200', 'Raumkosten (Miete)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4210', 'Heizung', 'Aufwand', 'VSt19', '66', 1), ('4210', 'Heizung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4240', 'Gas, Strom, Wasser', 'Aufwand', 'VSt19', '66', 1), ('4240', 'Gas, Strom, Wasser', 'Betriebsausgabe', 'VSt19', '66', 1),
('4250', 'Reinigung', 'Aufwand', 'VSt19', '66', 1), ('4250', 'Reinigung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4260', 'Gebäudeversicherungen', 'Aufwand', NULL, NULL, 1), ('4260', 'Gebäudeversicherungen', 'Betriebsausgabe', NULL, NULL, 1),
('4300', 'Betriebliche Versicherungen', 'Aufwand', NULL, NULL, 1), ('4300', 'Betriebliche Versicherungen', 'Betriebsausgabe', NULL, NULL, 1),
('4320', 'Beiträge', 'Aufwand', NULL, NULL, 1), ('4320', 'Beiträge', 'Betriebsausgabe', NULL, NULL, 1),
('4330', 'Berufsgenossenschaft', 'Aufwand', NULL, NULL, 1), ('4330', 'Berufsgenossenschaft', 'Betriebsausgabe', NULL, NULL, 1),
('4360', 'Beiträge zu Berufsverbänden', 'Aufwand', NULL, NULL, 1), ('4360', 'Beiträge zu Berufsverbänden', 'Betriebsausgabe', NULL, NULL, 1),
('4380', 'Verwaltungs-Gemeinkosten', 'Aufwand', 'VSt19', '66', 1), ('4380', 'Verwaltungs-Gemeinkosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Aufwand', 'VSt19', '66', 1), ('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4400', 'PKW-Kosten', 'Aufwand', 'VSt19', '66', 1), ('4400', 'PKW-Kosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4410', 'LKW-Kosten', 'Aufwand', 'VSt19', '66', 1), ('4410', 'LKW-Kosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4430', 'Kraftfahrzeugsteuer', 'Aufwand', NULL, NULL, 1), ('4430', 'Kraftfahrzeugsteuer', 'Betriebsausgabe', NULL, NULL, 1),
('4460', 'Reisekosten Arbeitnehmer', 'Aufwand', 'VSt19', '66', 1), ('4460', 'Reisekosten Arbeitnehmer', 'Betriebsausgabe', 'VSt19', '66', 1),
('4500', 'Werbekosten', 'Aufwand', 'VSt19', '66', 1), ('4500', 'Werbekosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Aufwand', 'VSt19', '66', 1), ('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4520', 'Repräsentationskosten', 'Aufwand', 'VSt19', '66', 1), ('4520', 'Repräsentationskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4530', 'Messekosten', 'Aufwand', 'VSt19', '66', 1), ('4530', 'Messekosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4540', 'Bewirtungskosten', 'Aufwand', 'VSt19', '66', 1), ('4540', 'Bewirtungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4560', 'Buchführungskosten', 'Aufwand', 'VSt19', '66', 1), ('4560', 'Buchführungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4570', 'Jahresabschluss- und Prüfungskosten', 'Aufwand', 'VSt19', '66', 1), ('4570', 'Jahresabschluss- und Prüfungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4580', 'Beratungskosten', 'Aufwand', 'VSt19', '66', 1), ('4580', 'Beratungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4600', 'Bürobedarf', 'Aufwand', 'VSt19', '66', 1), ('4600', 'Bürobedarf', 'Betriebsausgabe', 'VSt19', '66', 1),
('4610', 'Zeitschriften, Bücher', 'Aufwand', 'VSt7', '66', 1), ('4610', 'Zeitschriften, Bücher', 'Betriebsausgabe', 'VSt7', '66', 1),
('4620', 'Portokosten', 'Aufwand', NULL, NULL, 1), ('4620', 'Portokosten', 'Betriebsausgabe', NULL, NULL, 1),
('4630', 'Telefon', 'Aufwand', 'VSt19', '66', 1), ('4630', 'Telefon', 'Betriebsausgabe', 'VSt19', '66', 1),
('4640', 'Internetkosten', 'Aufwand', 'VSt19', '66', 1), ('4640', 'Internetkosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4650', 'Büromaschinen (Wartung/Reparatur)', 'Aufwand', 'VSt19', '66', 1), ('4650', 'Büromaschinen (Wartung/Reparatur)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4660', 'EDV-Kosten (Software, Lizenzen)', 'Aufwand', 'VSt19', '66', 1), ('4660', 'EDV-Kosten (Software, Lizenzen)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4670', 'Betriebliche Fortbildungskosten', 'Aufwand', 'VSt19', '66', 1), ('4670', 'Betriebliche Fortbildungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4680', 'Fachliteratur', 'Aufwand', 'VSt7', '66', 1), ('4680', 'Fachliteratur', 'Betriebsausgabe', 'VSt7', '66', 1),
('4700', 'Abschreibungen auf Anlagevermögen', 'Aufwand', NULL, NULL, 1), ('4700', 'Abschreibungen auf Anlagevermögen', 'Betriebsausgabe', NULL, NULL, 1),
('4710', 'Abschreibungen auf GWG', 'Aufwand', NULL, NULL, 1), ('4710', 'Abschreibungen auf GWG', 'Betriebsausgabe', NULL, NULL, 1),
('4800', 'Bankgebühren', 'Aufwand', NULL, NULL, 1), ('4800', 'Bankgebühren', 'Betriebsausgabe', NULL, NULL, 1),
('4820', 'Zinsen und ähnliche Aufwendungen', 'Aufwand', NULL, NULL, 1), ('4820', 'Zinsen und ähnliche Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4830', 'Mahngebühren, Inkassokosten', 'Aufwand', NULL, NULL, 1), ('4830', 'Mahngebühren, Inkassokosten', 'Betriebsausgabe', NULL, NULL, 1),
('4900', 'Sonstige betriebliche Aufwendungen', 'Aufwand', 'VSt19', '66', 1), ('4900', 'Sonstige betriebliche Aufwendungen', 'Betriebsausgabe', 'VSt19', '66', 1),
('4920', 'Gewerbesteuer', 'Aufwand', NULL, NULL, 1), ('4920', 'Gewerbesteuer', 'Betriebsausgabe', NULL, NULL, 1),
('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Aufwand', NULL, NULL, 1), ('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Betriebsausgabe', NULL, NULL, 1),
('4950', 'Sonstige Steuern', 'Aufwand', NULL, NULL, 1), ('4950', 'Sonstige Steuern', 'Betriebsausgabe', NULL, NULL, 1),
('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Aufwand', NULL, NULL, 1), ('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Betriebsausgabe', NULL, NULL, 1),
('4970', 'Aufwendungen aus Verlust Forderungen', 'Aufwand', NULL, NULL, 1), ('4970', 'Aufwendungen aus Verlust Forderungen', 'Betriebsausgabe', NULL, NULL, 1),
-- ============================================================ -- ============================================================
-- KLASSE 8: Erträge -- KLASSE 8: Erträge
-- ============================================================ -- ============================================================
('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1), ('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Betriebseinnahme', 'USt19', '81', 1),
('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1), ('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Betriebseinnahme', 'USt7', '86', 1),
('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Ertrag', NULL, '35', 1), ('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Betriebseinnahme', NULL, '35', 1),
('8125', 'Steuerfreie EU-Lieferungen', 'Ertrag', NULL, '41', 1), ('8125', 'Steuerfreie EU-Lieferungen', 'Betriebseinnahme', NULL, '41', 1),
('8130', 'Sonstige steuerfreie Umsätze', 'Ertrag', NULL, NULL, 1), ('8130', 'Sonstige steuerfreie Umsätze', 'Betriebseinnahme', NULL, NULL, 1),
('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1), ('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Betriebseinnahme', 'USt19', '81', 1),
('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1), ('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Betriebseinnahme', 'USt7', '86', 1),
('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Ertrag', 'USt19', '81', 1), ('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Betriebseinnahme', 'USt19', '81', 1),
('8500', 'Zinsen und ähnliche Erträge', 'Ertrag', NULL, NULL, 1), ('8500', 'Zinsen und ähnliche Erträge', 'Betriebseinnahme', NULL, NULL, 1),
('8600', 'Erträge aus Beteiligungen', 'Ertrag', NULL, NULL, 1), ('8600', 'Erträge aus Beteiligungen', 'Betriebseinnahme', NULL, NULL, 1),
('8900', 'Sonstige betriebliche Erträge', 'Ertrag', NULL, NULL, 1), ('8900', 'Sonstige betriebliche Erträge', 'Betriebseinnahme', NULL, NULL, 1),
('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Ertrag', NULL, NULL, 1), ('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Betriebseinnahme', NULL, NULL, 1),
('8920', 'Erträge aus Auflösung Rückstellungen', 'Ertrag', NULL, NULL, 1), ('8920', 'Erträge aus Auflösung Rückstellungen', 'Betriebseinnahme', NULL, NULL, 1),
('8940', 'Erträge aus Kursgewinnen', 'Ertrag', NULL, NULL, 1), ('8940', 'Erträge aus Kursgewinnen', 'Betriebseinnahme', NULL, NULL, 1),
-- ============================================================ -- ============================================================
-- KLASSE 9: Eigenkapital / Privat (Neutral) -- KLASSE 9: Eigenkapital / Privat (Neutral)
-- ============================================================ -- ============================================================
('9000', 'Eigenkapital', 'Neutral', NULL, NULL, 1), ('9000', 'Eigenkapital', 'Privat', NULL, NULL, 1),
('9100', 'Privatentnahmen', 'Neutral', NULL, NULL, 1), ('9100', 'Privatentnahmen', 'Privat', NULL, NULL, 1),
('9200', 'Privateinlagen', 'Neutral', NULL, NULL, 1), ('9200', 'Privateinlagen', 'Privat', NULL, NULL, 1),
('9300', 'Gewinnvortrag', 'Neutral', NULL, NULL, 1), ('9300', 'Gewinnvortrag', 'Privat', NULL, NULL, 1),
('9400', 'Verlustvortrag', 'Neutral', NULL, NULL, 1); ('9400', 'Verlustvortrag', 'Privat', NULL, NULL, 1);

View file

@ -337,13 +337,13 @@ function transferKontoNr(select) {
} }
document.addEventListener('dblclick', (e) => { document.addEventListener('dblclick', (e) => {
const select = e.target.closest('select#soll_konto_id, select#haben_konto_id'); const select = e.target.closest('select#kategorie_konto_id, select#zahlungskonto_id');
if (select) transferKontoNr(select); if (select) transferKontoNr(select);
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return; if (e.key !== 'Enter') return;
const select = e.target.closest('select#soll_konto_id, select#haben_konto_id'); const select = e.target.closest('select#kategorie_konto_id, select#zahlungskonto_id');
if (select) { if (select) {
e.preventDefault(); e.preventDefault();
transferKontoNr(select); transferKontoNr(select);

View file

@ -19,24 +19,22 @@ class AccountController
// Quick stats // Quick stats
$stats = []; $stats = [];
// Umsatz (Ertrag-Konten) // Einnahmen
$umsatz = \Steuerkram\Database::fetchOne( $umsatz = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk FROM bookings bk
JOIN accounts a ON bk.haben_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE a.kontenart = \'Ertrag\' AND fy.year = ?', WHERE bk.buchungstyp = \'Einnahme\' AND bk.is_reversal = 0 AND fy.year = ?',
[$year] [$year]
); );
$stats['umsatz'] = (float)($umsatz['total'] ?? 0); $stats['umsatz'] = (float)($umsatz['total'] ?? 0);
// Ausgaben (Aufwand-Konten) // Ausgaben
$ausgaben = \Steuerkram\Database::fetchOne( $ausgaben = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk FROM bookings bk
JOIN accounts a ON bk.soll_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE a.kontenart = \'Aufwand\' AND fy.year = ?', WHERE bk.buchungstyp = \'Ausgabe\' AND bk.is_reversal = 0 AND fy.year = ?',
[$year] [$year]
); );
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0); $stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
@ -59,12 +57,12 @@ class AccountController
// Letzte Buchungen // Letzte Buchungen
$recentBookings = \Steuerkram\Database::fetchAll( $recentBookings = \Steuerkram\Database::fetchAll(
'SELECT bk.*, s.kontonummer as soll_nr, s.bezeichnung as soll_name, 'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name, z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
bl.beleg_nr bl.beleg_nr
FROM bookings bk FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts h ON bk.haben_konto_id = h.id JOIN accounts z ON bk.zahlungskonto_id = z.id
JOIN belege bl ON bk.beleg_id = bl.id JOIN belege bl ON bk.beleg_id = bl.id
ORDER BY bk.created_at DESC LIMIT 10' ORDER BY bk.created_at DESC LIMIT 10'
); );

View file

@ -55,8 +55,10 @@ class BookingController
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel
fputcsv($out, [ fputcsv($out, [
'Buchungsnr.', 'Datum', 'Belegnr.', 'Soll-Kto.', 'Soll-Bez.', 'Buchungsnr.', 'Datum', 'Belegnr.', 'Typ',
'Haben-Kto.', 'Haben-Bez.', 'Netto (€)', 'USt (%)', 'USt (€)', 'Kategorie-Kto.', 'Kategorie-Bez.',
'Zahlungskonto-Kto.', 'Zahlungskonto-Bez.',
'Netto (€)', 'USt (%)', 'USt (€)',
'Brutto (€)', 'Buchungstext', 'Storno' 'Brutto (€)', 'Buchungstext', 'Storno'
], ';'); ], ';');
@ -65,10 +67,11 @@ class BookingController
$this->csvSanitize((string)$row['buchungs_nr']), $this->csvSanitize((string)$row['buchungs_nr']),
$row['buchungs_datum'], $row['buchungs_datum'],
$this->csvSanitize((string)$row['beleg_nr']), $this->csvSanitize((string)$row['beleg_nr']),
$row['soll_nr'], $row['buchungstyp'],
$this->csvSanitize((string)$row['soll_name']), $row['kategorie_nr'],
$row['haben_nr'], $this->csvSanitize((string)$row['kategorie_name']),
$this->csvSanitize((string)$row['haben_name']), $row['zahlungs_nr'],
$this->csvSanitize((string)$row['zahlungs_name']),
number_format((float)$row['betrag_netto'], 2, ',', ''), number_format((float)$row['betrag_netto'], 2, ',', ''),
number_format((float)$row['ust_satz'], 2, ',', ''), number_format((float)$row['ust_satz'], 2, ',', ''),
number_format((float)$row['betrag_ust'], 2, ',', ''), number_format((float)$row['betrag_ust'], 2, ',', ''),
@ -87,15 +90,16 @@ class BookingController
$errors = []; $errors = [];
// Validate // Validate
if (empty($data['soll_konto_id'])) $errors['soll_konto_id'] = 'Pflichtfeld'; if (empty($data['buchungstyp'])) $errors['buchungstyp'] = 'Pflichtfeld';
if (empty($data['haben_konto_id'])) $errors['haben_konto_id'] = 'Pflichtfeld'; if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld';
if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld'; if (empty($data['zahlungskonto_id'])) $errors['zahlungskonto_id'] = 'Pflichtfeld';
if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld';
if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '') if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '')
$errors['betrag_netto'] = 'Pflichtfeld'; $errors['betrag_netto'] = 'Pflichtfeld';
if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld'; if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld';
if (empty($errors) && $data['soll_konto_id'] === $data['haben_konto_id']) { if (empty($errors) && $data['kategorie_konto_id'] === $data['zahlungskonto_id']) {
$errors['haben_konto_id'] = 'Soll- und Habenkonto dürfen nicht identisch sein.'; $errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.';
} }
if ($errors) { if ($errors) {

View file

@ -246,11 +246,11 @@ class InvoiceController
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'], 'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
]); ]);
$debitorKonto = \Steuerkram\Database::fetchOne( $zahlungskonto = \Steuerkram\Database::fetchOne(
'SELECT id FROM accounts WHERE kontonummer = ?', ['1400'] 'SELECT id FROM accounts WHERE kontonummer = ?', ['1400']
); );
if (!$debitorKonto) { if (!$zahlungskonto) {
throw new \RuntimeException('Debitorenkonto 1400 nicht gefunden.'); throw new \RuntimeException('Forderungskonto 1400 nicht gefunden.');
} }
$erloesKonten = []; $erloesKonten = [];
@ -270,24 +270,25 @@ class InvoiceController
foreach ($gruppenNetto as $satz => $netto) { foreach ($gruppenNetto as $satz => $netto) {
if (abs($satz - 19) < 0.01) { if (abs($satz - 19) < 0.01) {
$habenId = $erloesKonten['8000'] ?? null; $kategorieId = $erloesKonten['8000'] ?? null;
} elseif (abs($satz - 7) < 0.01) { } elseif (abs($satz - 7) < 0.01) {
$habenId = $erloesKonten['8200'] ?? null; $kategorieId = $erloesKonten['8200'] ?? null;
} else { } else {
$habenId = $erloesKonten['8400'] ?? null; $kategorieId = $erloesKonten['8400'] ?? null;
} }
if (!$habenId) { if (!$kategorieId) {
throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden."); throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden.");
} }
Booking::create([ Booking::create([
'beleg_id' => $belegId, 'beleg_id' => $belegId,
'buchungs_datum' => $invoice['rechnungs_datum'], 'buchungs_datum' => $invoice['rechnungs_datum'],
'soll_konto_id' => $debitorKonto['id'], 'buchungstyp' => 'Einnahme',
'haben_konto_id' => $habenId, 'kategorie_konto_id' => $kategorieId,
'betrag_netto' => round($netto, 2), 'zahlungskonto_id' => $zahlungskonto['id'],
'ust_satz' => $satz, 'betrag_netto' => round($netto, 2),
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'], 'ust_satz' => $satz,
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'],
]); ]);
} }

View file

@ -90,7 +90,7 @@ class Account
{ {
// Check if any bookings reference this account // Check if any bookings reference this account
$used = Database::fetchOne( $used = Database::fetchOne(
'SELECT COUNT(*) as cnt FROM bookings WHERE soll_konto_id = ? OR haben_konto_id = ?', 'SELECT COUNT(*) as cnt FROM bookings WHERE kategorie_konto_id = ? OR zahlungskonto_id = ?',
[$id, $id] [$id, $id]
); );
if ($used && $used['cnt'] > 0) { if ($used && $used['cnt'] > 0) {

View file

@ -12,11 +12,11 @@ class Booking
{ {
return Database::fetchAll( return Database::fetchAll(
'SELECT bk.*, 'SELECT bk.*,
s.kontonummer as soll_nr, s.bezeichnung as soll_name, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name
FROM bookings bk FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts h ON bk.haben_konto_id = h.id JOIN accounts z ON bk.zahlungskonto_id = z.id
WHERE bk.beleg_id = ? WHERE bk.beleg_id = ?
ORDER BY bk.buchungs_datum, bk.id', ORDER BY bk.buchungs_datum, bk.id',
[$belegId] [$belegId]
@ -37,7 +37,7 @@ class Booking
$params[] = (int)$filter['month']; $params[] = (int)$filter['month'];
} }
if (!empty($filter['konto'])) { if (!empty($filter['konto'])) {
$where[] = '(bk.soll_konto_id = ? OR bk.haben_konto_id = ?)'; $where[] = '(bk.kategorie_konto_id = ? OR bk.zahlungskonto_id = ?)';
$params[] = (int)$filter['konto']; $params[] = (int)$filter['konto'];
$params[] = (int)$filter['konto']; $params[] = (int)$filter['konto'];
} }
@ -48,12 +48,12 @@ class Booking
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$sql = 'SELECT bk.*, $sql = 'SELECT bk.*,
s.kontonummer as soll_nr, s.bezeichnung as soll_name, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name, z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
bl.beleg_nr, fy.year as fiscal_year_nr bl.beleg_nr, fy.year as fiscal_year_nr
FROM bookings bk FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts h ON bk.haben_konto_id = h.id JOIN accounts z ON bk.zahlungskonto_id = z.id
JOIN belege bl ON bk.beleg_id = bl.id JOIN belege bl ON bk.beleg_id = bl.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE ' . implode(' AND ', $where) . ' WHERE ' . implode(' AND ', $where) . '
@ -97,15 +97,17 @@ class Booking
$nr = self::generateBuchungsNr(); $nr = self::generateBuchungsNr();
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id, 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, fiscal_year_id) betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$nr, $nr,
(int)$data['beleg_id'], (int)$data['beleg_id'],
$data['buchungs_datum'], $data['buchungs_datum'],
(int)$data['soll_konto_id'], $data['buchungstyp'],
(int)$data['haben_konto_id'], (int)$data['kategorie_konto_id'],
(int)$data['zahlungskonto_id'],
$netto, $netto,
(float)($data['ust_satz'] ?? 0), (float)($data['ust_satz'] ?? 0),
$ust, $ust,
@ -129,15 +131,15 @@ class Booking
if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.'); if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.');
if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.'); if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.');
// Create reversal: swap Soll/Haben, same amounts
$reversalData = [ $reversalData = [
'beleg_id' => $orig['beleg_id'], 'beleg_id' => $orig['beleg_id'],
'buchungs_datum' => date('Y-m-d'), 'buchungs_datum' => date('Y-m-d'),
'soll_konto_id' => $orig['haben_konto_id'], 'buchungstyp' => $orig['buchungstyp'],
'haben_konto_id' => $orig['soll_konto_id'], 'kategorie_konto_id' => $orig['kategorie_konto_id'],
'betrag_netto' => $orig['betrag_netto'], 'zahlungskonto_id' => $orig['zahlungskonto_id'],
'ust_satz' => $orig['ust_satz'], 'betrag_netto' => $orig['betrag_netto'],
'buchungstext' => 'STORNO zu ' . $orig['buchungs_nr'] . ': ' . $reason, 'ust_satz' => $orig['ust_satz'],
'buchungstext' => 'STORNO zu ' . $orig['buchungs_nr'] . ': ' . $reason,
]; ];
$year = (int)date('Y', strtotime($reversalData['buchungs_datum'])); $year = (int)date('Y', strtotime($reversalData['buchungs_datum']));
@ -146,15 +148,17 @@ class Booking
$nr = self::generateBuchungsNr(); $nr = self::generateBuchungsNr();
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id, 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[ [
$nr, $nr,
$reversalData['beleg_id'], $reversalData['beleg_id'],
$reversalData['buchungs_datum'], $reversalData['buchungs_datum'],
$reversalData['soll_konto_id'], $reversalData['buchungstyp'],
$reversalData['haben_konto_id'], $reversalData['kategorie_konto_id'],
$reversalData['zahlungskonto_id'],
$orig['betrag_netto'], $orig['betrag_netto'],
$orig['ust_satz'], $orig['ust_satz'],
$orig['betrag_ust'], $orig['betrag_ust'],
@ -186,14 +190,14 @@ class Booking
public static function sumByYear(int $year): array public static function sumByYear(int $year): array
{ {
return Database::fetchAll( return Database::fetchAll(
'SELECT bk.soll_konto_id, bk.haben_konto_id, 'SELECT bk.buchungstyp, bk.kategorie_konto_id, bk.zahlungskonto_id,
SUM(bk.betrag_netto) as sum_netto, SUM(bk.betrag_netto) as sum_netto,
SUM(bk.betrag_ust) as sum_ust, SUM(bk.betrag_ust) as sum_ust,
SUM(bk.betrag_brutto) as sum_brutto SUM(bk.betrag_brutto) as sum_brutto
FROM bookings bk FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? WHERE fy.year = ?
GROUP BY bk.soll_konto_id, bk.haben_konto_id', GROUP BY bk.buchungstyp, bk.kategorie_konto_id, bk.zahlungskonto_id',
[$year] [$year]
); );
} }

View file

@ -8,7 +8,7 @@ use Steuerkram\Database;
/** /**
* Berechnet die Einnahmen-Überschuss-Rechnung (EÜR) für ein Geschäftsjahr. * Berechnet die Einnahmen-Überschuss-Rechnung (EÜR) für ein Geschäftsjahr.
* Gruppiert alle Buchungen nach Konten und summiert Einnahmen / Ausgaben. * Gruppiert alle Buchungen nach buchungstyp und Kategorie-Konto.
*/ */
class EuerCalculator class EuerCalculator
{ {
@ -20,14 +20,14 @@ class EuerCalculator
a.kontonummer, a.kontonummer,
a.bezeichnung, a.bezeichnung,
a.kontenart, a.kontenart,
SUM(CASE WHEN bk.haben_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as haben_summe, bk.buchungstyp,
SUM(CASE WHEN bk.soll_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as soll_summe, SUM(bk.betrag_netto) as summe_netto,
COUNT(bk.id) as buchungsanzahl COUNT(bk.id) as buchungsanzahl
FROM accounts a FROM bookings bk
JOIN bookings bk ON (bk.haben_konto_id = a.id OR bk.soll_konto_id = a.id) JOIN accounts a ON bk.kategorie_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND bk.is_reversal = 0 WHERE fy.year = ? AND bk.is_reversal = 0
GROUP BY a.id, a.kontonummer, a.bezeichnung, a.kontenart GROUP BY a.id, a.kontonummer, a.bezeichnung, a.kontenart, bk.buchungstyp
HAVING buchungsanzahl > 0 HAVING buchungsanzahl > 0
ORDER BY a.kontonummer', ORDER BY a.kontonummer',
[$year] [$year]
@ -37,12 +37,12 @@ class EuerCalculator
$ausgaben = []; $ausgaben = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$nettoWert = round((float)$row['haben_summe'] - (float)$row['soll_summe'], 2); $netto = round(abs((float)$row['summe_netto']), 2);
if ($row['kontenart'] === 'Ertrag') { if ($row['buchungstyp'] === 'Einnahme') {
$einnahmen[] = array_merge($row, ['netto' => abs($nettoWert)]); $einnahmen[] = array_merge($row, ['netto' => $netto]);
} elseif ($row['kontenart'] === 'Aufwand') { } elseif ($row['buchungstyp'] === 'Ausgabe') {
$ausgaben[] = array_merge($row, ['netto' => abs($nettoWert)]); $ausgaben[] = array_merge($row, ['netto' => $netto]);
} }
} }

View file

@ -9,15 +9,12 @@ use Steuerkram\Database;
/** /**
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung. * Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
* *
* Maßgeblich sind ausschließlich ust_satz und betrag_ust der Buchung selbst – * Maßgeblich sind buchungstyp, ust_satz und betrag_ust der Buchung selbst.
* nicht der steuercode des gebuchten Kontos. Dadurch werden Buchungen mit
* ust_satz=0 (z.B. innergemeinschaftliche Erwerbe, steuerfreie Leistungen)
* korrekt mit 0 Vorsteuer/USt gewertet.
* *
* Buchungslogik: * Buchungslogik:
* Umsatzsteuer (Kz 81/86): ust_satz > 0 auf Ertrag-Haben-Konten * Umsatzsteuer (Kz 81/86): buchungstyp = 'Einnahme' mit ust_satz > 0
* Vorsteuer (Kz 61/62): ust_satz > 0 auf Aufwand/Aktiv-Soll-Konten * Vorsteuer (Kz 61/62): buchungstyp = 'Ausgabe' mit ust_satz > 0
* Steuerfreie Umsätze (Kz 35): Ertrag-Konten mit ust_satz = 0 * Steuerfreie Umsätze (Kz 35): buchungstyp = 'Einnahme' mit ust_satz = 0
*/ */
class UstvaCalculator class UstvaCalculator
{ {
@ -39,15 +36,13 @@ class UstvaCalculator
$bookings = $this->fetchBookings($year, $month); $bookings = $this->fetchBookings($year, $month);
foreach ($bookings as $b) { foreach ($bookings as $b) {
$ustSatz = (float)$b['ust_satz']; $ustSatz = (float)$b['ust_satz'];
$betragUst = abs((float)$b['betrag_ust']); $betragUst = abs((float)$b['betrag_ust']);
$betragNetto= abs((float)$b['betrag_netto']); $betragNetto = abs((float)$b['betrag_netto']);
$habenArt = $b['haben_kontenart'] ?? ''; $buchungstyp = $b['buchungstyp'];
$sollArt = $b['soll_kontenart'] ?? '';
// ── Umsatzsteuer (Ausgangsseite) ────────────────────────────── // ── Umsatzsteuer (Einnahmen) ─────────────────────────────
// Kriterium: Haben-Konto = Ertrag UND ust_satz > 0 if ($buchungstyp === 'Einnahme' && $ustSatz > 0) {
if ($habenArt === 'Ertrag' && $ustSatz > 0) {
if (abs($ustSatz - 19) < 0.01) { if (abs($ustSatz - 19) < 0.01) {
$kz['81']['betrag'] += $betragNetto; $kz['81']['betrag'] += $betragNetto;
} elseif (abs($ustSatz - 7) < 0.01) { } elseif (abs($ustSatz - 7) < 0.01) {
@ -55,16 +50,13 @@ class UstvaCalculator
} }
} }
// Steuerfreie Umsätze: Haben-Konto = Ertrag UND ust_satz = 0 // Steuerfreie Umsätze: Einnahme mit ust_satz = 0
if ($habenArt === 'Ertrag' && $ustSatz == 0) { if ($buchungstyp === 'Einnahme' && $ustSatz == 0) {
$kz['35']['betrag'] += $betragNetto; $kz['35']['betrag'] += $betragNetto;
} }
// ── Vorsteuer (Eingangsseite) ────────────────────────────────── // ── Vorsteuer (Ausgaben) ─────────────────────────────────
// Kriterium: Soll-Konto = Aufwand oder Aktiv (Vorsteuerkonto) if ($buchungstyp === 'Ausgabe' && $ustSatz > 0 && $betragUst > 0) {
// UND ust_satz > 0 UND betrag_ust > 0
// Buchungen mit ust_satz=0 (z.B. innergemeinschaftlich) → kein Eintrag
if (in_array($sollArt, ['Aufwand', 'Aktiv']) && $ustSatz > 0 && $betragUst > 0) {
if (abs($ustSatz - 19) < 0.01) { if (abs($ustSatz - 19) < 0.01) {
$kz['61']['steuer'] += $betragUst; $kz['61']['steuer'] += $betragUst;
} elseif (abs($ustSatz - 7) < 0.01) { } elseif (abs($ustSatz - 7) < 0.01) {
@ -92,12 +84,8 @@ class UstvaCalculator
private function fetchBookings(int $year, int $month): array private function fetchBookings(int $year, int $month): array
{ {
return Database::fetchAll( return Database::fetchAll(
'SELECT bk.ust_satz, bk.betrag_netto, bk.betrag_ust, 'SELECT bk.buchungstyp, bk.ust_satz, bk.betrag_netto, bk.betrag_ust
s.kontenart AS soll_kontenart,
h.kontenart AS haben_kontenart
FROM bookings bk FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.is_reversal = 0', AND bk.is_reversal = 0',

View file

@ -22,7 +22,7 @@
<select id="kontenart" name="kontenart" <select id="kontenart" name="kontenart"
class="<?= isset($errors['kontenart']) ? 'input-error' : '' ?>" required> class="<?= isset($errors['kontenart']) ? 'input-error' : '' ?>" required>
<option value="">– wählen –</option> <option value="">– wählen –</option>
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?> <?php foreach (['Betriebseinnahme','Betriebsausgabe','Zahlungskonto','Verbindlichkeit','Privat'] as $art): ?>
<option value="<?= $h($art) ?>"<?= ($account['kontenart'] ?? '') === $art ? ' selected' : '' ?>> <option value="<?= $h($art) ?>"<?= ($account['kontenart'] ?? '') === $art ? ' selected' : '' ?>>
<?= $h($art) ?> <?= $h($art) ?>
</option> </option>

View file

@ -7,7 +7,7 @@
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px"> value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
<select name="kontenart"> <select name="kontenart">
<option value="">Alle Kontenarten</option> <option value="">Alle Kontenarten</option>
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?> <?php foreach (['Betriebseinnahme','Betriebsausgabe','Zahlungskonto','Verbindlichkeit','Privat'] as $art): ?>
<option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option> <option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@ -44,11 +44,11 @@
<td> <td>
<?php <?php
$badgeClass = match($acc['kontenart']) { $badgeClass = match($acc['kontenart']) {
'Ertrag' => 'badge-green', 'Betriebseinnahme' => 'badge-green',
'Aufwand' => 'badge-red', 'Betriebsausgabe' => 'badge-red',
'Aktiv' => 'badge-blue', 'Zahlungskonto' => 'badge-blue',
'Passiv' => 'badge-yellow', 'Verbindlichkeit' => 'badge-yellow',
default => 'badge-gray', default => 'badge-gray',
}; };
?> ?>
<span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span> <span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span>

View file

@ -149,8 +149,9 @@ $testMode = Setting::isTestMode();
<tr> <tr>
<th>Buchungsnr.</th> <th>Buchungsnr.</th>
<th>Datum</th> <th>Datum</th>
<th>Soll</th> <th>Typ</th>
<th>Haben</th> <th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Netto</th> <th class="td-right">Netto</th>
<th class="td-right">USt</th> <th class="td-right">USt</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
@ -166,8 +167,9 @@ $testMode = Setting::isTestMode();
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>"> <tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td> <td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td> <td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td class="td-mono text-sm"><?= $h($bk['soll_nr']) ?><br><span class="td-muted"><?= $h($bk['soll_name']) ?></span></td> <td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['haben_nr']) ?><br><span class="td-muted"><?= $h($bk['haben_name']) ?></span></td> <td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td> <td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td> <td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td> <td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
@ -220,7 +222,7 @@ $testMode = Setting::isTestMode();
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($bookings)): ?> <?php if (empty($bookings)): ?>
<tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr> <tr><td colspan="10" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -232,6 +234,17 @@ $testMode = Setting::isTestMode();
<?php <?php
$formErrors = $formErrors ?? []; $formErrors = $formErrors ?? [];
$formData = $formData ?? []; $formData = $formData ?? [];
// Default buchungstyp aus Belegtyp ableiten
$defaultTyp = $formData['buchungstyp'] ?? (
$beleg['belegtyp'] === 'Ausgangsrechnung' ? 'Einnahme' : 'Ausgabe'
);
// Konten nach Art gruppieren
$kontenGruppen = [];
foreach ($accounts as $acc) {
$kontenGruppen[$acc['kontenart']][] = $acc;
}
?> ?>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@ -249,6 +262,25 @@ $testMode = Setting::isTestMode();
<?php endif; ?> <?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings"> <form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<!-- Buchungstyp -->
<div class="form-group">
<label>Buchungstyp *</label>
<div style="display:flex;gap:1rem">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="radio" name="buchungstyp" value="Ausgabe"
<?= $defaultTyp === 'Ausgabe' ? 'checked' : '' ?>
onchange="filterKategorieKonten(this.value)">
<span class="badge badge-red">Betriebsausgabe</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="radio" name="buchungstyp" value="Einnahme"
<?= $defaultTyp === 'Einnahme' ? 'checked' : '' ?>
onchange="filterKategorieKonten(this.value)">
<span class="badge badge-green">Betriebseinnahme</span>
</label>
</div>
</div>
<div class="form-grid form-grid-2"> <div class="form-grid form-grid-2">
<div class="form-group"> <div class="form-group">
<label>Buchungsdatum *</label> <label>Buchungsdatum *</label>
@ -266,28 +298,19 @@ $testMode = Setting::isTestMode();
</div> </div>
</div> </div>
<?php <!-- Kategorie-Konto -->
// Konten nach Art gruppieren für <optgroup>
$kontenGruppen = [];
foreach ($accounts as $acc) {
$kontenGruppen[$acc['kontenart']][] = $acc;
}
$gruppenReihenfolge = ['Aufwand', 'Ertrag', 'Aktiv', 'Passiv', 'Neutral'];
?>
<!-- Soll-Konto -->
<div class="form-group"> <div class="form-group">
<label for="soll_konto_id">Soll-Konto * <label for="kategorie_konto_id">Kategorie *
<span id="soll_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span> <span id="kategorie_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label> </label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter" <input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="soll_konto_id" autocomplete="off" style="margin-bottom:.3rem"> data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="soll_konto_id" name="soll_konto_id" required size="6" <select id="kategorie_konto_id" name="kategorie_konto_id" required size="6"
onchange="showKontoArt('soll_konto_id','soll_konto_art',this)"> onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this)">
<option value="">– Konto wählen –</option> <option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?> <?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Privat'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?> <?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──"> <optgroup label="── <?= $h($art) ?> ──" data-kontenart="<?= $h($art) ?>">
<?php foreach ($kontenGruppen[$art] as $acc): ?> <?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>" <option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>" data-art="<?= $h($acc['kontenart']) ?>"
@ -298,20 +321,19 @@ $testMode = Setting::isTestMode();
</optgroup> </optgroup>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<div class="form-hint">Typisch: <strong>Aufwand</strong>-Konto (Eingangsrechnung) oder <strong>Aktiv</strong>-Konto (Forderung/Bank)</div>
</div> </div>
<!-- Haben-Konto --> <!-- Zahlungskonto -->
<div class="form-group"> <div class="form-group">
<label for="haben_konto_id">Haben-Konto * <label for="zahlungskonto_id">Zahlungskonto *
<span id="haben_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span> <span id="zahlungskonto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label> </label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter" <input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="haben_konto_id" autocomplete="off" style="margin-bottom:.3rem"> data-target="zahlungskonto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="haben_konto_id" name="haben_konto_id" required size="6" <select id="zahlungskonto_id" name="zahlungskonto_id" required size="4"
onchange="showKontoArt('haben_konto_id','haben_konto_art',this)"> onchange="showKontoArt('zahlungskonto_id','zahlungskonto_art',this)">
<option value="">– Konto wählen –</option> <option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?> <?php foreach (['Zahlungskonto', 'Verbindlichkeit'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?> <?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──"> <optgroup label="── <?= $h($art) ?> ──">
<?php foreach ($kontenGruppen[$art] as $acc): ?> <?php foreach ($kontenGruppen[$art] as $acc): ?>
@ -324,7 +346,6 @@ $testMode = Setting::isTestMode();
</optgroup> </optgroup>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<div class="form-hint">Typisch: <strong>Passiv</strong>-Konto (Verbindlichkeit) oder <strong>Ertrag</strong>-Konto (Ausgangsrechnung)</div>
</div> </div>
<div class="form-grid form-grid-2"> <div class="form-grid form-grid-2">
@ -360,3 +381,30 @@ $testMode = Setting::isTestMode();
</div> </div>
</div> </div>
<script>
function filterKategorieKonten(buchungstyp) {
const select = document.getElementById('kategorie_konto_id');
const groups = select.querySelectorAll('optgroup');
const zielArt = buchungstyp === 'Einnahme' ? 'Betriebseinnahme' : 'Betriebsausgabe';
groups.forEach(function(g) {
const art = g.getAttribute('data-kontenart');
if (!art) return;
// Privat immer anzeigen, Kategorie nach Typ filtern
g.style.display = (art === zielArt || art === 'Privat') ? '' : 'none';
});
// Auswahl zurücksetzen wenn aktuell gewähltes Konto nicht mehr sichtbar
if (select.selectedIndex > 0) {
const selected = select.options[select.selectedIndex];
if (selected && selected.closest('optgroup') && selected.closest('optgroup').style.display === 'none') {
select.selectedIndex = 0;
}
}
}
// Initial filtern
document.addEventListener('DOMContentLoaded', function() {
const checked = document.querySelector('input[name="buchungstyp"]:checked');
if (checked) filterKategorieKonten(checked.value);
});
</script>

View file

@ -42,8 +42,9 @@
<th>Buchungsnr.</th> <th>Buchungsnr.</th>
<th>Datum</th> <th>Datum</th>
<th>Belegnr.</th> <th>Belegnr.</th>
<th>Soll-Konto</th> <th>Typ</th>
<th>Haben-Konto</th> <th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Netto</th> <th class="td-right">Netto</th>
<th class="td-right">USt (%)</th> <th class="td-right">USt (%)</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
@ -63,13 +64,14 @@
</td> </td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td> <td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td><a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a></td> <td><a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a></td>
<td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm"> <td class="td-mono text-sm">
<?= $h($bk['soll_nr']) ?><br> <?= $h($bk['kategorie_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['soll_name'], 0, 25, '…')) ?></span> <span class="td-muted"><?= $h(mb_strimwidth($bk['kategorie_name'], 0, 25, '…')) ?></span>
</td> </td>
<td class="td-mono text-sm"> <td class="td-mono text-sm">
<?= $h($bk['haben_nr']) ?><br> <?= $h($bk['zahlungs_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['haben_name'], 0, 25, '…')) ?></span> <span class="td-muted"><?= $h(mb_strimwidth($bk['zahlungs_name'], 0, 25, '…')) ?></span>
</td> </td>
<td class="td-right td-mono"><?= View::formatMoney($bk['betrag_netto']) ?></td> <td class="td-right td-mono"><?= View::formatMoney($bk['betrag_netto']) ?></td>
<td class="td-right td-muted"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td> <td class="td-right td-muted"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
@ -93,7 +95,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($rows)): ?> <?php if (empty($rows)): ?>
<tr><td colspan="<?= $testMode ? 10 : 9 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr> <tr><td colspan="<?= $testMode ? 11 : 10 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View file

@ -151,8 +151,9 @@ $fmt = fn($v) => View::formatMoney($v);
<tr> <tr>
<th>Datum</th> <th>Datum</th>
<th>Belegnr.</th> <th>Belegnr.</th>
<th>Soll</th> <th>Typ</th>
<th>Haben</th> <th>Kategorie</th>
<th>Zahlungskonto</th>
<th>Buchungstext</th> <th>Buchungstext</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
</tr> </tr>
@ -162,14 +163,15 @@ $fmt = fn($v) => View::formatMoney($v);
<tr> <tr>
<td class="td-muted"><?= View::formatDate($b['buchungs_datum']) ?></td> <td class="td-muted"><?= View::formatDate($b['buchungs_datum']) ?></td>
<td><a href="/belege/<?= $h($b['beleg_id']) ?>"><?= $h($b['beleg_nr']) ?></a></td> <td><a href="/belege/<?= $h($b['beleg_id']) ?>"><?= $h($b['beleg_nr']) ?></a></td>
<td class="td-mono"><?= $h($b['soll_nr']) ?> <span class="text-muted">– <?= $h($b['soll_name']) ?></span></td> <td><span class="badge <?= $b['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($b['buchungstyp']) ?></span></td>
<td class="td-mono"><?= $h($b['haben_nr']) ?> <span class="text-muted">– <?= $h($b['haben_name']) ?></span></td> <td class="td-mono"><?= $h($b['kategorie_nr']) ?> <span class="text-muted">– <?= $h($b['kategorie_name']) ?></span></td>
<td class="td-mono"><?= $h($b['zahlungs_nr']) ?> <span class="text-muted">– <?= $h($b['zahlungs_name']) ?></span></td>
<td><?= $h($b['buchungstext']) ?><?php if ($b['is_reversal']): ?> <span class="badge badge-red">Storno</span><?php endif; ?></td> <td><?= $h($b['buchungstext']) ?><?php if ($b['is_reversal']): ?> <span class="badge badge-red">Storno</span><?php endif; ?></td>
<td class="td-right td-mono"><?= $fmt($b['betrag_brutto']) ?></td> <td class="td-right td-mono"><?= $fmt($b['betrag_brutto']) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($recentBookings)): ?> <?php if (empty($recentBookings)): ?>
<tr><td colspan="6" class="text-center text-muted" style="padding:2rem">Noch keine Buchungen vorhanden.</td></tr> <tr><td colspan="7" class="text-center text-muted" style="padding:2rem">Noch keine Buchungen vorhanden.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View file

@ -15,26 +15,26 @@ class BelegBuchungTest extends TestCase
public function run(): void public function run(): void
{ {
// Konten aus der SKR03-Seed-Datei abrufen // Konten aus der SKR03-Seed-Datei abrufen
$aufwandId = $this->findAccountId('4920'); // Bürobedarf (Aufwand) $ausgabeId = $this->findAccountId('4920'); // Bürobedarf (Betriebsausgabe)
$bankId = $this->findAccountId('1200'); // Bank (Aktiv) $bankId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
if (!$aufwandId || !$bankId) { if (!$ausgabeId || !$bankId) {
// Fallback: irgendein Aufwand- und Aktiv-Konto suchen // Fallback: irgendein Betriebsausgabe- und Zahlungskonto suchen
$aufwandRow = \Steuerkram\Database::fetchOne( $ausgabeRow = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Aufwand' AND aktiv = 1 LIMIT 1" "SELECT id FROM accounts WHERE kontenart = 'Betriebsausgabe' AND aktiv = 1 LIMIT 1"
); );
$bankRow = \Steuerkram\Database::fetchOne( $bankRow = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 LIMIT 1" "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 LIMIT 1"
); );
$aufwandId = $aufwandRow ? (int)$aufwandRow['id'] : null; $ausgabeId = $ausgabeRow ? (int)$ausgabeRow['id'] : null;
$bankId = $bankRow ? (int)$bankRow['id'] : null; $bankId = $bankRow ? (int)$bankRow['id'] : null;
} }
$this->assertNotEmpty($aufwandId, 'Aufwandskonto gefunden'); $this->assertNotEmpty($ausgabeId, 'Betriebsausgabe-Konto gefunden');
$this->assertNotEmpty($bankId, 'Aktiv-/Bankkonto gefunden'); $this->assertNotEmpty($bankId, 'Zahlungskonto gefunden');
if (!$aufwandId || !$bankId) { if (!$ausgabeId || !$bankId) {
return; // Voraussetzungen nicht erfüllt, weitere Asserts wären sinnlos return;
} }
// Beleg anlegen // Beleg anlegen
@ -54,15 +54,16 @@ class BelegBuchungTest extends TestCase
]); ]);
$this->assertGreaterThan(0, $belegId, 'Beleg für Buchungstest angelegt'); $this->assertGreaterThan(0, $belegId, 'Beleg für Buchungstest angelegt');
// Buchung erstellen // Buchung erstellen (EÜR: Ausgabe mit Kategorie + Zahlungskonto)
$buchungId = \Steuerkram\Models\Booking::create([ $buchungId = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId, 'beleg_id' => $belegId,
'buchungs_datum' => $datum, 'buchungs_datum' => $datum,
'soll_konto_id' => $aufwandId, 'buchungstyp' => 'Ausgabe',
'haben_konto_id' => $bankId, 'kategorie_konto_id' => $ausgabeId,
'betrag_netto' => 50.00, 'zahlungskonto_id' => $bankId,
'ust_satz' => 19.0, 'betrag_netto' => 50.00,
'buchungstext' => 'Testbuchung Bürobedarf', 'ust_satz' => 19.0,
'buchungstext' => 'Testbuchung Bürobedarf',
]); ]);
$this->assertGreaterThan(0, $buchungId, 'Buchung wurde angelegt (ID > 0)'); $this->assertGreaterThan(0, $buchungId, 'Buchung wurde angelegt (ID > 0)');
@ -71,6 +72,7 @@ class BelegBuchungTest extends TestCase
$this->assertNotEmpty($buchung, 'Booking::find() liefert die Buchung'); $this->assertNotEmpty($buchung, 'Booking::find() liefert die Buchung');
$this->assertEquals(50.00, (float)($buchung['betrag_netto'] ?? 0), 'Nettobetrag korrekt'); $this->assertEquals(50.00, (float)($buchung['betrag_netto'] ?? 0), 'Nettobetrag korrekt');
$this->assertEquals(19.0, (float)($buchung['ust_satz'] ?? 0), 'USt-Satz korrekt'); $this->assertEquals(19.0, (float)($buchung['ust_satz'] ?? 0), 'USt-Satz korrekt');
$this->assertEquals('Ausgabe', $buchung['buchungstyp'] ?? '', 'Buchungstyp ist Ausgabe');
// Buchung via Booking::forBeleg() abrufbar // Buchung via Booking::forBeleg() abrufbar
$buchungen = \Steuerkram\Models\Booking::forBeleg($belegId); $buchungen = \Steuerkram\Models\Booking::forBeleg($belegId);
@ -93,11 +95,11 @@ class BelegBuchungTest extends TestCase
foreach ($journal['rows'] as $row) { foreach ($journal['rows'] as $row) {
if ((int)$row['id'] === $buchungId) { if ((int)$row['id'] === $buchungId) {
$foundInJournal = true; $foundInJournal = true;
// Vollständigkeit prüfen $this->assertNotEmpty($row['buchungs_nr'], 'Buchungsnummer im Journal vorhanden');
$this->assertNotEmpty($row['buchungs_nr'], 'Buchungsnummer im Journal vorhanden'); $this->assertNotEmpty($row['kategorie_nr'], 'Kategorie-Kontonummer im Journal vorhanden');
$this->assertNotEmpty($row['soll_nr'], 'Soll-Kontonummer im Journal vorhanden'); $this->assertNotEmpty($row['zahlungs_nr'], 'Zahlungskonto-Nr. im Journal vorhanden');
$this->assertNotEmpty($row['haben_nr'], 'Haben-Kontonummer im Journal vorhanden'); $this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden');
$this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden'); $this->assertEquals('Ausgabe', $row['buchungstyp'], 'Buchungstyp im Journal korrekt');
break; break;
} }
} }

View file

@ -16,14 +16,14 @@ class InvoiceBuchungTest extends TestCase
public function run(): void public function run(): void
{ {
// Benötigte Konten prüfen (SKR03) // Benötigte Konten prüfen (SKR03)
$debitorId = $this->findAccountId('1400'); // Debitorenkonto $zahlungsId = $this->findAccountId('1400'); // Forderungen (Zahlungskonto)
$erloesId = $this->findAccountId('8000'); // Erlöse 19 % $kategorieId = $this->findAccountId('8000'); // Erlöse 19 % (Betriebseinnahme)
if (!$debitorId) { if (!$zahlungsId) {
$this->assert(false, 'Debitorenkonto 1400 nicht gefunden – SKR03-Seed vorhanden?'); $this->assert(false, 'Forderungskonto 1400 nicht gefunden – SKR03-Seed vorhanden?');
return; return;
} }
if (!$erloesId) { if (!$kategorieId) {
$this->assert(false, 'Erlöskonto 8000 nicht gefunden – SKR03-Seed vorhanden?'); $this->assert(false, 'Erlöskonto 8000 nicht gefunden – SKR03-Seed vorhanden?');
return; return;
} }
@ -65,7 +65,7 @@ class InvoiceBuchungTest extends TestCase
$invoice = \Steuerkram\Models\Invoice::find($invoiceId); $invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Rechnungsstatus ist Gesendet'); $this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Rechnungsstatus ist Gesendet');
// Buchungen erzeugen (wie InvoiceController::createBelegAndBookings) // Buchungen erzeugen (EÜR: Einnahme)
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr(); $belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
$belegId = \Steuerkram\Models\Beleg::create([ $belegId = \Steuerkram\Models\Beleg::create([
'beleg_nr' => $belegNr, 'beleg_nr' => $belegNr,
@ -91,13 +91,14 @@ class InvoiceBuchungTest extends TestCase
$buchungIds = []; $buchungIds = [];
foreach ($gruppenNetto as $satz => $netto) { foreach ($gruppenNetto as $satz => $netto) {
$buchungIds[] = \Steuerkram\Models\Booking::create([ $buchungIds[] = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId, 'beleg_id' => $belegId,
'buchungs_datum' => $invoice['rechnungs_datum'], 'buchungs_datum' => $invoice['rechnungs_datum'],
'soll_konto_id' => $debitorId, 'buchungstyp' => 'Einnahme',
'haben_konto_id' => $erloesId, 'kategorie_konto_id' => $kategorieId,
'betrag_netto' => round($netto, 2), 'zahlungskonto_id' => $zahlungsId,
'ust_satz' => $satz, 'betrag_netto' => round($netto, 2),
'buchungstext' => 'Ausgangsrechnung ' . $nr, 'ust_satz' => $satz,
'buchungstext' => 'Ausgangsrechnung ' . $nr,
]); ]);
} }
@ -124,9 +125,10 @@ class InvoiceBuchungTest extends TestCase
$this->assertEquals(19.0, (float)$row['ust_satz'], 'USt-Satz 19 % korrekt'); $this->assertEquals(19.0, (float)$row['ust_satz'], 'USt-Satz 19 % korrekt');
$this->assertEquals(190.00, (float)$row['betrag_ust'], 'USt-Betrag 190 € korrekt'); $this->assertEquals(190.00, (float)$row['betrag_ust'], 'USt-Betrag 190 € korrekt');
$this->assertEquals(1190.00, (float)$row['betrag_brutto'], 'Bruttobetrag 1190 € korrekt'); $this->assertEquals(1190.00, (float)$row['betrag_brutto'], 'Bruttobetrag 1190 € korrekt');
$this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden'); $this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden');
$this->assertNotEmpty($row['soll_nr'], 'Soll-Konto im Journal vorhanden'); $this->assertNotEmpty($row['kategorie_nr'], 'Kategorie-Konto im Journal vorhanden');
$this->assertNotEmpty($row['haben_nr'], 'Haben-Konto im Journal vorhanden'); $this->assertNotEmpty($row['zahlungs_nr'], 'Zahlungskonto im Journal vorhanden');
$this->assertEquals('Einnahme', $row['buchungstyp'], 'Buchungstyp ist Einnahme');
break; break;
} }
} }

View file

@ -19,46 +19,46 @@ class TaxSummenTest extends TestCase
$datum = date('Y-m-d'); $datum = date('Y-m-d');
// Benötigte Konten (SKR03) // Benötigte Konten (SKR03)
$debitorId = $this->findAccountId('1400'); // Forderungen (Aktiv) $zahlungsId = $this->findAccountId('1400'); // Forderungen (Zahlungskonto)
$erloesId = $this->findAccountId('8000'); // Erlöse 19 % (Ertrag) $erloesId = $this->findAccountId('8000'); // Erlöse 19 % (Betriebseinnahme)
$aufwandId = $this->findAccountId('4920'); // Bürobedarf (Aufwand) $ausgabeId = $this->findAccountId('4920'); // Bürobedarf (Betriebsausgabe)
$bankId = $this->findAccountId('1200'); // Bank (Aktiv) $bankId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
// Fallback auf beliebige Konten der passenden Art // Fallback auf beliebige Konten der passenden Art
if (!$erloesId) { if (!$erloesId) {
$row = \Steuerkram\Database::fetchOne( $row = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Ertrag' AND aktiv = 1 LIMIT 1" "SELECT id FROM accounts WHERE kontenart = 'Betriebseinnahme' AND aktiv = 1 LIMIT 1"
); );
$erloesId = $row ? (int)$row['id'] : null; $erloesId = $row ? (int)$row['id'] : null;
} }
if (!$aufwandId) { if (!$ausgabeId) {
$row = \Steuerkram\Database::fetchOne( $row = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Aufwand' AND aktiv = 1 LIMIT 1" "SELECT id FROM accounts WHERE kontenart = 'Betriebsausgabe' AND aktiv = 1 LIMIT 1"
); );
$aufwandId = $row ? (int)$row['id'] : null; $ausgabeId = $row ? (int)$row['id'] : null;
} }
if (!$debitorId) { if (!$zahlungsId) {
$row = \Steuerkram\Database::fetchOne( $row = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 LIMIT 1" "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 LIMIT 1"
); );
$debitorId = $row ? (int)$row['id'] : null; $zahlungsId = $row ? (int)$row['id'] : null;
} }
if (!$bankId) { if (!$bankId) {
$row = \Steuerkram\Database::fetchOne( $row = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 AND id != ? LIMIT 1", "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 AND id != ? LIMIT 1",
[$debitorId ?? 0] [$zahlungsId ?? 0]
); );
$bankId = $row ? (int)$row['id'] : null; $bankId = $row ? (int)$row['id'] : null;
} }
if (!$debitorId || !$erloesId || !$aufwandId || !$bankId) { if (!$zahlungsId || !$erloesId || !$ausgabeId || !$bankId) {
$this->assert(false, 'Benötigte Konten nicht gefunden – SKR03-Seed vorhanden?'); $this->assert(false, 'Benötigte Konten nicht gefunden – SKR03-Seed vorhanden?');
return; return;
} }
$this->assert(true, 'Alle benötigten Konten gefunden'); $this->assert(true, 'Alle benötigten Konten gefunden');
// ── Ausgangsbuchung (Ertrag, 19 %) ──────────────────────────────── // ── Einnahme-Buchung (19 %) ─────────────────────────────────
// Debitor an Erlös 19%: Netto=500, USt=95, Brutto=595 // Erlöse 19%: Netto=500, USt=95, Brutto=595
$belegAr = \Steuerkram\Models\Beleg::create([ $belegAr = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-AR-' . uniqid(), 'beleg_nr' => 'TEST-AR-' . uniqid(),
'filename' => 'test-ar.pdf', 'filename' => 'test-ar.pdf',
@ -70,17 +70,18 @@ class TaxSummenTest extends TestCase
'belegtyp' => 'Ausgangsrechnung', 'belegtyp' => 'Ausgangsrechnung',
]); ]);
\Steuerkram\Models\Booking::create([ \Steuerkram\Models\Booking::create([
'beleg_id' => $belegAr, 'beleg_id' => $belegAr,
'buchungs_datum' => $datum, 'buchungs_datum' => $datum,
'soll_konto_id' => $debitorId, 'buchungstyp' => 'Einnahme',
'haben_konto_id' => $erloesId, 'kategorie_konto_id' => $erloesId,
'betrag_netto' => 500.00, 'zahlungskonto_id' => $zahlungsId,
'ust_satz' => 19.0, 'betrag_netto' => 500.00,
'buchungstext' => 'Test Ausgangsbuchung USt 19%', 'ust_satz' => 19.0,
'buchungstext' => 'Test Einnahme USt 19%',
]); ]);
// ── Eingangsbuchung (Aufwand, 19 %) ─────────────────────────────── // ── Ausgabe-Buchung (19 %) ──────────────────────────────────
// Aufwand an Bank: Netto=200, USt=38, Brutto=238 // Bürobedarf: Netto=200, USt=38, Brutto=238
$belegEr = \Steuerkram\Models\Beleg::create([ $belegEr = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-ER-' . uniqid(), 'beleg_nr' => 'TEST-ER-' . uniqid(),
'filename' => 'test-er.pdf', 'filename' => 'test-er.pdf',
@ -92,13 +93,14 @@ class TaxSummenTest extends TestCase
'belegtyp' => 'Eingangsrechnung', 'belegtyp' => 'Eingangsrechnung',
]); ]);
\Steuerkram\Models\Booking::create([ \Steuerkram\Models\Booking::create([
'beleg_id' => $belegEr, 'beleg_id' => $belegEr,
'buchungs_datum' => $datum, 'buchungs_datum' => $datum,
'soll_konto_id' => $aufwandId, 'buchungstyp' => 'Ausgabe',
'haben_konto_id' => $bankId, 'kategorie_konto_id' => $ausgabeId,
'betrag_netto' => 200.00, 'zahlungskonto_id' => $bankId,
'ust_satz' => 19.0, 'betrag_netto' => 200.00,
'buchungstext' => 'Test Eingangsbuchung USt 19%', 'ust_satz' => 19.0,
'buchungstext' => 'Test Ausgabe USt 19%',
]); ]);
// ── UStVA prüfen ────────────────────────────────────────────────── // ── UStVA prüfen ──────────────────────────────────────────────────
@ -108,7 +110,7 @@ class TaxSummenTest extends TestCase
// Kz 81: Steuerpfl. Umsätze 19% – muss mindestens 500 € enthalten // Kz 81: Steuerpfl. Umsätze 19% – muss mindestens 500 € enthalten
$this->assert( $this->assert(
(float)$kz['81']['betrag'] >= 500.00, (float)$kz['81']['betrag'] >= 500.00,
'UStVA Kz 81 (Umsätze 19%): Betrag enthält Ausgangsbuchung ≥ 500 €' 'UStVA Kz 81 (Umsätze 19%): Betrag enthält Einnahme ≥ 500 €'
); );
// Kz 83: USt auf Umsätze 19% – abgeleitet (19% von Kz81-Betrag) // Kz 83: USt auf Umsätze 19% – abgeleitet (19% von Kz81-Betrag)
@ -120,7 +122,7 @@ class TaxSummenTest extends TestCase
// Kz 61: Vorsteuer 19% – muss mindestens 38 € enthalten // Kz 61: Vorsteuer 19% – muss mindestens 38 € enthalten
$this->assert( $this->assert(
(float)$kz['61']['steuer'] >= 38.00, (float)$kz['61']['steuer'] >= 38.00,
'UStVA Kz 61 (Vorsteuer 19%): Steuerbetrag enthält Eingangsbuchung ≥ 38 €' 'UStVA Kz 61 (Vorsteuer 19%): Steuerbetrag enthält Ausgabe ≥ 38 €'
); );
// Kz 66: Vorsteuer gesamt – muss ≥ Kz 61 sein // Kz 66: Vorsteuer gesamt – muss ≥ Kz 61 sein
@ -143,13 +145,13 @@ class TaxSummenTest extends TestCase
// Einnahmen müssen vorhanden sein // Einnahmen müssen vorhanden sein
$this->assert( $this->assert(
(float)$euer['total_einnahmen'] >= 500.00, (float)$euer['total_einnahmen'] >= 500.00,
'EÜR: Einnahmen ≥ 500 € (Ausgangsbuchung eingeflossen)' 'EÜR: Einnahmen ≥ 500 € (Einnahme-Buchung eingeflossen)'
); );
// Ausgaben müssen vorhanden sein // Ausgaben müssen vorhanden sein
$this->assert( $this->assert(
(float)$euer['total_ausgaben'] >= 200.00, (float)$euer['total_ausgaben'] >= 200.00,
'EÜR: Ausgaben ≥ 200 € (Eingangsbuchung eingeflossen)' 'EÜR: Ausgaben ≥ 200 € (Ausgabe-Buchung eingeflossen)'
); );
// Gewinn = Einnahmen - Ausgaben // Gewinn = Einnahmen - Ausgaben

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* Test 6: EÜR-Buchungsmodell – Einnahmen und Ausgaben.
*
* Prüft das EÜR-Buchungsmodell: buchungstyp, kategorie_konto_id, zahlungskonto_id.
* Stellt sicher, dass Stornobuchungen korrekt funktionieren und
* die Kontenarten den neuen EÜR-Bezeichnungen entsprechen.
*/
class EuerBuchungsTest extends TestCase
{
protected string $testName = 'Test 6: EÜR-Buchungsmodell (Einnahme/Ausgabe)';
public function run(): void
{
$datum = date('Y-m-d');
// ── Kontenarten prüfen ───────────────────────────────────────
$kontenarten = \Steuerkram\Database::fetchAll(
"SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart"
);
$arten = array_column($kontenarten, 'kontenart');
$this->assert(
in_array('Betriebseinnahme', $arten),
'Kontenart "Betriebseinnahme" existiert'
);
$this->assert(
in_array('Betriebsausgabe', $arten),
'Kontenart "Betriebsausgabe" existiert'
);
$this->assert(
in_array('Zahlungskonto', $arten),
'Kontenart "Zahlungskonto" existiert'
);
$this->assert(
!in_array('Aktiv', $arten) && !in_array('Passiv', $arten)
&& !in_array('Aufwand', $arten) && !in_array('Ertrag', $arten),
'Alte Kontenarten (Aktiv, Passiv, Aufwand, Ertrag) existieren nicht mehr'
);
// Konten laden
$erloesId = $this->findAccountId('8000'); // Betriebseinnahme
$ausgabeId = $this->findAccountId('4600'); // Bürobedarf (Betriebsausgabe)
$bankId = $this->findAccountId('1200'); // Bank (Zahlungskonto)
if (!$erloesId || !$ausgabeId || !$bankId) {
$this->assert(false, 'Benötigte Konten (8000, 4600, 1200) nicht gefunden');
return;
}
// ── Einnahme-Buchung ─────────────────────────────────────────
$belegEinnahme = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-EIN-' . uniqid(),
'filename' => 'test-einnahme.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'einnahme-' . uniqid()),
'beleg_datum' => $datum,
'eingangs_datum' => $datum,
'belegtyp' => 'Ausgangsrechnung',
]);
$einnahmeId = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegEinnahme,
'buchungs_datum' => $datum,
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $erloesId,
'zahlungskonto_id' => $bankId,
'betrag_netto' => 1000.00,
'ust_satz' => 19.0,
'buchungstext' => 'Test Einnahme',
]);
$einnahme = \Steuerkram\Models\Booking::find($einnahmeId);
$this->assertEquals('Einnahme', $einnahme['buchungstyp'], 'Einnahme-Buchung hat buchungstyp=Einnahme');
$this->assertEquals($erloesId, (int)$einnahme['kategorie_konto_id'], 'Kategorie-Konto ist Erlöskonto');
$this->assertEquals($bankId, (int)$einnahme['zahlungskonto_id'], 'Zahlungskonto ist Bankkonto');
$this->assertEquals(1000.00, (float)$einnahme['betrag_netto'], 'Nettobetrag korrekt');
$this->assertEquals(190.00, (float)$einnahme['betrag_ust'], 'USt-Betrag korrekt berechnet');
$this->assertEquals(1190.00, (float)$einnahme['betrag_brutto'], 'Bruttobetrag korrekt berechnet');
// ── Ausgabe-Buchung ──────────────────────────────────────────
$belegAusgabe = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-AUS-' . uniqid(),
'filename' => 'test-ausgabe.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'ausgabe-' . uniqid()),
'beleg_datum' => $datum,
'eingangs_datum' => $datum,
'belegtyp' => 'Eingangsrechnung',
]);
$ausgabeBuchungId = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegAusgabe,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $bankId,
'betrag_netto' => 300.00,
'ust_satz' => 19.0,
'buchungstext' => 'Test Ausgabe',
]);
$ausgabe = \Steuerkram\Models\Booking::find($ausgabeBuchungId);
$this->assertEquals('Ausgabe', $ausgabe['buchungstyp'], 'Ausgabe-Buchung hat buchungstyp=Ausgabe');
$this->assertEquals($ausgabeId, (int)$ausgabe['kategorie_konto_id'], 'Kategorie-Konto ist Ausgabenkonto');
// ── Storno prüfen ────────────────────────────────────────────
$stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund');
$storno = \Steuerkram\Models\Booking::find($stornoId);
$this->assertEquals('Ausgabe', $storno['buchungstyp'], 'Storno behält buchungstyp der Originalbuchung');
$this->assertEquals(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt');
$this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto');
$this->assertEquals($bankId, (int)$storno['zahlungskonto_id'], 'Storno behält Zahlungskonto');
// Original hat reversed_by
$origNeu = \Steuerkram\Models\Booking::find($ausgabeBuchungId);
$this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung');
// ── EÜR-Berechnung ───────────────────────────────────────────
$euer = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
$this->assert(
(float)$euer['total_einnahmen'] >= 1000.00,
'EÜR: Einnahmen enthalten die 1000 € Einnahme-Buchung'
);
// Ausgabe wurde storniert → sollte nicht in den Ausgaben erscheinen (is_reversal=0 Filter)
$this->assert(
(float)$euer['gewinn'] > 0,
'EÜR: Gewinn ist positiv (Einnahmen > Ausgaben nach Storno)'
);
}
}