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,
kontonummer VARCHAR(10) NOT NULL UNIQUE,
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,
steuercode VARCHAR(20) NULL COMMENT 'z.B. USt19, VSt19, USt7, VSt7',
ustva_kz VARCHAR(10) NULL COMMENT 'ELSTER Kennzahl z.B. 81, 35',
@ -122,7 +122,7 @@ CREATE TABLE IF NOT EXISTS belege (
) 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
-- ============================================================
CREATE TABLE IF NOT EXISTS bookings (
@ -130,8 +130,9 @@ CREATE TABLE IF NOT EXISTS bookings (
buchungs_nr VARCHAR(30) NOT NULL UNIQUE,
beleg_id INT UNSIGNED NOT NULL,
buchungs_datum DATE NOT NULL,
soll_konto_id INT UNSIGNED NOT NULL,
haben_konto_id INT UNSIGNED NOT NULL,
buchungstyp ENUM('Einnahme','Ausgabe') NOT NULL,
kategorie_konto_id INT UNSIGNED NOT NULL COMMENT 'Was? z.B. Büromaterial, Honorar',
zahlungskonto_id INT UNSIGNED NOT NULL COMMENT 'Wie bezahlt? z.B. Bank, Kasse',
betrag_netto DECIMAL(12,2) NOT NULL,
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'Steuersatz in % (0, 7, 19)',
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
@ -143,13 +144,14 @@ CREATE TABLE IF NOT EXISTS bookings (
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_soll FOREIGN KEY (soll_konto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_haben FOREIGN KEY (haben_konto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_kategorie FOREIGN KEY (kategorie_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),
INDEX idx_buchungs_datum (buchungs_datum),
INDEX idx_fiscal_year (fiscal_year_id),
INDEX idx_soll_konto (soll_konto_id),
INDEX idx_haben_konto (haben_konto_id)
INDEX idx_buchungstyp (buchungstyp),
INDEX idx_kategorie_konto (kategorie_konto_id),
INDEX idx_zahlungskonto (zahlungskonto_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 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
-- Kontenarten: Aktiv, Passiv, Aufwand, Ertrag, Neutral
-- Kontenarten: Betriebseinnahme, Betriebsausgabe, Zahlungskonto, Verbindlichkeit, Privat
-- steuercode: USt19, USt7, VSt19, VSt7, NULL (kein Steuercode)
-- ustva_kz: ELSTER-Kennzahl für UStVA
@ -8,116 +8,116 @@ INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz,
-- ============================================================
-- KLASSE 0: Anlagevermögen
-- ============================================================
('0410', 'EDV-Software', 'Aktiv', NULL, NULL, 1),
('0420', 'Büroeinrichtung', 'Aktiv', NULL, NULL, 1),
('0430', 'Technische Anlagen und Maschinen', 'Aktiv', NULL, NULL, 1),
('0480', 'Betriebs- und Geschäftsausstattung', 'Aktiv', NULL, NULL, 1),
('0490', 'Geringwertige Wirtschaftsgüter', 'Aktiv', NULL, NULL, 1),
('0410', 'EDV-Software', 'Zahlungskonto', NULL, NULL, 1),
('0420', 'Büroeinrichtung', 'Zahlungskonto', NULL, NULL, 1),
('0430', 'Technische Anlagen und Maschinen', 'Zahlungskonto', NULL, NULL, 1),
('0480', 'Betriebs- und Geschäftsausstattung', 'Zahlungskonto', NULL, NULL, 1),
('0490', 'Geringwertige Wirtschaftsgüter', 'Zahlungskonto', NULL, NULL, 1),
-- ============================================================
-- KLASSE 1: Umlaufvermögen / Geldkonten
-- ============================================================
('1000', 'Kasse', 'Aktiv', NULL, NULL, 1),
('1200', 'Bank', 'Aktiv', NULL, NULL, 1),
('1361', 'Abziehbare Vorsteuer 19 %', 'Aktiv', 'VSt19', '66', 1),
('1371', 'Abziehbare Vorsteuer 7 %', 'Aktiv', 'VSt7', '66', 1),
('1400', 'Forderungen aus Lieferungen und Leistungen', 'Aktiv', NULL, NULL, 1),
('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Aktiv', NULL, NULL, 1),
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Aktiv', 'VSt19', '66', 1),
('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Aktiv', 'VSt7', '66', 1),
('1000', 'Kasse', 'Zahlungskonto', NULL, NULL, 1),
('1200', 'Bank', 'Zahlungskonto', NULL, NULL, 1),
('1361', 'Abziehbare Vorsteuer 19 %', 'Zahlungskonto', 'VSt19', '66', 1),
('1371', 'Abziehbare Vorsteuer 7 %', 'Zahlungskonto', 'VSt7', '66', 1),
('1400', 'Forderungen aus Lieferungen und Leistungen', 'Zahlungskonto', NULL, NULL, 1),
('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Zahlungskonto', NULL, NULL, 1),
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Zahlungskonto', 'VSt19', '66', 1),
('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Zahlungskonto', 'VSt7', '66', 1),
-- ============================================================
-- KLASSE 1: Verbindlichkeiten (Passiv)
-- ============================================================
('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Passiv', NULL, NULL, 1),
('1700', 'Sonstige Verbindlichkeiten', 'Passiv', NULL, NULL, 1),
('1741', 'Umsatzsteuer 19 %', 'Passiv', 'USt19', '83', 1),
('1742', 'Umsatzsteuer 7 %', 'Passiv', 'USt7', '84', 1),
('1771', 'Umsatzsteuer-Vorauszahlungen', 'Passiv', NULL, NULL, 1),
('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Passiv', NULL, NULL, 1),
('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Verbindlichkeit', NULL, NULL, 1),
('1700', 'Sonstige Verbindlichkeiten', 'Verbindlichkeit', NULL, NULL, 1),
('1741', 'Umsatzsteuer 19 %', 'Verbindlichkeit', 'USt19', '83', 1),
('1742', 'Umsatzsteuer 7 %', 'Verbindlichkeit', 'USt7', '84', 1),
('1771', 'Umsatzsteuer-Vorauszahlungen', 'Verbindlichkeit', NULL, NULL, 1),
('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Verbindlichkeit', NULL, NULL, 1),
-- ============================================================
-- KLASSE 3: Wareneinkauf (Aufwand)
-- ============================================================
('3100', 'Wareneinkauf 19 % Vorsteuer', 'Aufwand', 'VSt19', '66', 1),
('3110', 'Wareneinkauf 7 % Vorsteuer', 'Aufwand', 'VSt7', '66', 1),
('3120', 'Wareneinkauf ohne Vorsteuer', 'Aufwand', NULL, NULL, 1),
('3100', 'Wareneinkauf 19 % Vorsteuer', 'Betriebsausgabe', 'VSt19', '66', 1),
('3110', 'Wareneinkauf 7 % Vorsteuer', 'Betriebsausgabe', 'VSt7', '66', 1),
('3120', 'Wareneinkauf ohne Vorsteuer', 'Betriebsausgabe', NULL, NULL, 1),
-- ============================================================
-- KLASSE 4: Betriebliche Aufwendungen
-- ============================================================
('4100', 'Gehälter', 'Aufwand', NULL, NULL, 1),
('4110', 'Löhne', 'Aufwand', NULL, NULL, 1),
('4120', 'Gesetzliche soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4130', 'Freiwillige soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4140', 'Aufwendungen für Altersversorgung', 'Aufwand', NULL, NULL, 1),
('4200', 'Raumkosten (Miete)', 'Aufwand', 'VSt19', '66', 1),
('4210', 'Heizung', 'Aufwand', 'VSt19', '66', 1),
('4240', 'Gas, Strom, Wasser', 'Aufwand', 'VSt19', '66', 1),
('4250', 'Reinigung', 'Aufwand', 'VSt19', '66', 1),
('4260', 'Gebäudeversicherungen', 'Aufwand', NULL, NULL, 1),
('4300', 'Betriebliche Versicherungen', 'Aufwand', NULL, NULL, 1),
('4320', 'Beiträge', 'Aufwand', NULL, NULL, 1),
('4330', 'Berufsgenossenschaft', 'Aufwand', NULL, NULL, 1),
('4360', 'Beiträge zu Berufsverbänden', 'Aufwand', NULL, NULL, 1),
('4380', 'Verwaltungs-Gemeinkosten', 'Aufwand', 'VSt19', '66', 1),
('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Aufwand', 'VSt19', '66', 1),
('4400', 'PKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
('4410', 'LKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
('4430', 'Kraftfahrzeugsteuer', 'Aufwand', NULL, NULL, 1),
('4460', 'Reisekosten Arbeitnehmer', 'Aufwand', 'VSt19', '66', 1),
('4500', 'Werbekosten', 'Aufwand', 'VSt19', '66', 1),
('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Aufwand', 'VSt19', '66', 1),
('4520', 'Repräsentationskosten', 'Aufwand', 'VSt19', '66', 1),
('4530', 'Messekosten', 'Aufwand', 'VSt19', '66', 1),
('4540', 'Bewirtungskosten', 'Aufwand', 'VSt19', '66', 1),
('4560', 'Buchführungskosten', 'Aufwand', 'VSt19', '66', 1),
('4570', 'Jahresabschluss- und Prüfungskosten', 'Aufwand', 'VSt19', '66', 1),
('4580', 'Beratungskosten', 'Aufwand', 'VSt19', '66', 1),
('4600', 'Bürobedarf', 'Aufwand', 'VSt19', '66', 1),
('4610', 'Zeitschriften, Bücher', 'Aufwand', 'VSt7', '66', 1),
('4620', 'Portokosten', 'Aufwand', NULL, NULL, 1),
('4630', 'Telefon', 'Aufwand', 'VSt19', '66', 1),
('4640', 'Internetkosten', 'Aufwand', 'VSt19', '66', 1),
('4650', 'Büromaschinen (Wartung/Reparatur)', 'Aufwand', 'VSt19', '66', 1),
('4660', 'EDV-Kosten (Software, Lizenzen)', 'Aufwand', 'VSt19', '66', 1),
('4670', 'Betriebliche Fortbildungskosten', 'Aufwand', 'VSt19', '66', 1),
('4680', 'Fachliteratur', 'Aufwand', 'VSt7', '66', 1),
('4700', 'Abschreibungen auf Anlagevermögen', 'Aufwand', NULL, NULL, 1),
('4710', 'Abschreibungen auf GWG', 'Aufwand', NULL, NULL, 1),
('4800', 'Bankgebühren', 'Aufwand', NULL, NULL, 1),
('4820', 'Zinsen und ähnliche Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4830', 'Mahngebühren, Inkassokosten', 'Aufwand', NULL, NULL, 1),
('4900', 'Sonstige betriebliche Aufwendungen', 'Aufwand', 'VSt19', '66', 1),
('4920', 'Gewerbesteuer', 'Aufwand', NULL, NULL, 1),
('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Aufwand', NULL, NULL, 1),
('4950', 'Sonstige Steuern', 'Aufwand', NULL, NULL, 1),
('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Aufwand', NULL, NULL, 1),
('4970', 'Aufwendungen aus Verlust Forderungen', 'Aufwand', NULL, NULL, 1),
('4100', 'Gehälter', 'Betriebsausgabe', NULL, NULL, 1),
('4110', 'Löhne', 'Betriebsausgabe', NULL, NULL, 1),
('4120', 'Gesetzliche soziale Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4130', 'Freiwillige soziale Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4140', 'Aufwendungen für Altersversorgung', 'Betriebsausgabe', NULL, NULL, 1),
('4200', 'Raumkosten (Miete)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4210', 'Heizung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4240', 'Gas, Strom, Wasser', 'Betriebsausgabe', 'VSt19', '66', 1),
('4250', 'Reinigung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4260', 'Gebäudeversicherungen', 'Betriebsausgabe', NULL, NULL, 1),
('4300', 'Betriebliche Versicherungen', 'Betriebsausgabe', NULL, NULL, 1),
('4320', 'Beiträge', 'Betriebsausgabe', NULL, NULL, 1),
('4330', 'Berufsgenossenschaft', 'Betriebsausgabe', NULL, NULL, 1),
('4360', 'Beiträge zu Berufsverbänden', 'Betriebsausgabe', NULL, NULL, 1),
('4380', 'Verwaltungs-Gemeinkosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Betriebsausgabe', 'VSt19', '66', 1),
('4400', 'PKW-Kosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4410', 'LKW-Kosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4430', 'Kraftfahrzeugsteuer', 'Betriebsausgabe', NULL, NULL, 1),
('4460', 'Reisekosten Arbeitnehmer', 'Betriebsausgabe', 'VSt19', '66', 1),
('4500', 'Werbekosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4520', 'Repräsentationskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4530', 'Messekosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4540', 'Bewirtungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4560', 'Buchführungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4570', 'Jahresabschluss- und Prüfungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4580', 'Beratungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4600', 'Bürobedarf', 'Betriebsausgabe', 'VSt19', '66', 1),
('4610', 'Zeitschriften, Bücher', 'Betriebsausgabe', 'VSt7', '66', 1),
('4620', 'Portokosten', 'Betriebsausgabe', NULL, NULL, 1),
('4630', 'Telefon', 'Betriebsausgabe', 'VSt19', '66', 1),
('4640', 'Internetkosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4650', 'Büromaschinen (Wartung/Reparatur)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4660', 'EDV-Kosten (Software, Lizenzen)', 'Betriebsausgabe', 'VSt19', '66', 1),
('4670', 'Betriebliche Fortbildungskosten', 'Betriebsausgabe', 'VSt19', '66', 1),
('4680', 'Fachliteratur', 'Betriebsausgabe', 'VSt7', '66', 1),
('4700', 'Abschreibungen auf Anlagevermögen', 'Betriebsausgabe', NULL, NULL, 1),
('4710', 'Abschreibungen auf GWG', 'Betriebsausgabe', NULL, NULL, 1),
('4800', 'Bankgebühren', 'Betriebsausgabe', NULL, NULL, 1),
('4820', 'Zinsen und ähnliche Aufwendungen', 'Betriebsausgabe', NULL, NULL, 1),
('4830', 'Mahngebühren, Inkassokosten', 'Betriebsausgabe', NULL, NULL, 1),
('4900', 'Sonstige betriebliche Aufwendungen', 'Betriebsausgabe', 'VSt19', '66', 1),
('4920', 'Gewerbesteuer', 'Betriebsausgabe', NULL, NULL, 1),
('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Betriebsausgabe', NULL, NULL, 1),
('4950', 'Sonstige Steuern', 'Betriebsausgabe', NULL, NULL, 1),
('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Betriebsausgabe', NULL, NULL, 1),
('4970', 'Aufwendungen aus Verlust Forderungen', 'Betriebsausgabe', NULL, NULL, 1),
-- ============================================================
-- KLASSE 8: Erträge
-- ============================================================
('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Ertrag', NULL, '35', 1),
('8125', 'Steuerfreie EU-Lieferungen', 'Ertrag', NULL, '41', 1),
('8130', 'Sonstige steuerfreie Umsätze', 'Ertrag', NULL, NULL, 1),
('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Ertrag', 'USt19', '81', 1),
('8500', 'Zinsen und ähnliche Erträge', 'Ertrag', NULL, NULL, 1),
('8600', 'Erträge aus Beteiligungen', 'Ertrag', NULL, NULL, 1),
('8900', 'Sonstige betriebliche Erträge', 'Ertrag', NULL, NULL, 1),
('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Ertrag', NULL, NULL, 1),
('8920', 'Erträge aus Auflösung Rückstellungen', 'Ertrag', NULL, NULL, 1),
('8940', 'Erträge aus Kursgewinnen', 'Ertrag', NULL, NULL, 1),
('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Betriebseinnahme', 'USt19', '81', 1),
('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Betriebseinnahme', 'USt7', '86', 1),
('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Betriebseinnahme', NULL, '35', 1),
('8125', 'Steuerfreie EU-Lieferungen', 'Betriebseinnahme', NULL, '41', 1),
('8130', 'Sonstige steuerfreie Umsätze', 'Betriebseinnahme', NULL, NULL, 1),
('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Betriebseinnahme', 'USt19', '81', 1),
('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Betriebseinnahme', 'USt7', '86', 1),
('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Betriebseinnahme', 'USt19', '81', 1),
('8500', 'Zinsen und ähnliche Erträge', 'Betriebseinnahme', NULL, NULL, 1),
('8600', 'Erträge aus Beteiligungen', 'Betriebseinnahme', NULL, NULL, 1),
('8900', 'Sonstige betriebliche Erträge', 'Betriebseinnahme', NULL, NULL, 1),
('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Betriebseinnahme', NULL, NULL, 1),
('8920', 'Erträge aus Auflösung Rückstellungen', 'Betriebseinnahme', NULL, NULL, 1),
('8940', 'Erträge aus Kursgewinnen', 'Betriebseinnahme', NULL, NULL, 1),
-- ============================================================
-- KLASSE 9: Eigenkapital / Privat (Neutral)
-- ============================================================
('9000', 'Eigenkapital', 'Neutral', NULL, NULL, 1),
('9100', 'Privatentnahmen', 'Neutral', NULL, NULL, 1),
('9200', 'Privateinlagen', 'Neutral', NULL, NULL, 1),
('9300', 'Gewinnvortrag', 'Neutral', NULL, NULL, 1),
('9400', 'Verlustvortrag', 'Neutral', NULL, NULL, 1);
('9000', 'Eigenkapital', 'Privat', NULL, NULL, 1),
('9100', 'Privatentnahmen', 'Privat', NULL, NULL, 1),
('9200', 'Privateinlagen', 'Privat', NULL, NULL, 1),
('9300', 'Gewinnvortrag', 'Privat', NULL, NULL, 1),
('9400', 'Verlustvortrag', 'Privat', NULL, NULL, 1);

View file

@ -337,13 +337,13 @@ function transferKontoNr(select) {
}
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);
});
document.addEventListener('keydown', (e) => {
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) {
e.preventDefault();
transferKontoNr(select);

View file

@ -19,24 +19,22 @@ class AccountController
// Quick stats
$stats = [];
// Umsatz (Ertrag-Konten)
// Einnahmen
$umsatz = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk
JOIN accounts a ON bk.haben_konto_id = a.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]
);
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
// Ausgaben (Aufwand-Konten)
// Ausgaben
$ausgaben = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk
JOIN accounts a ON bk.soll_konto_id = a.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]
);
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
@ -59,12 +57,12 @@ class AccountController
// Letzte Buchungen
$recentBookings = \Steuerkram\Database::fetchAll(
'SELECT bk.*, s.kontonummer as soll_nr, s.bezeichnung as soll_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name,
'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
bl.beleg_nr
FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id
JOIN belege bl ON bk.beleg_id = bl.id
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
fputcsv($out, [
'Buchungsnr.', 'Datum', 'Belegnr.', 'Soll-Kto.', 'Soll-Bez.',
'Haben-Kto.', 'Haben-Bez.', 'Netto (€)', 'USt (%)', 'USt (€)',
'Buchungsnr.', 'Datum', 'Belegnr.', 'Typ',
'Kategorie-Kto.', 'Kategorie-Bez.',
'Zahlungskonto-Kto.', 'Zahlungskonto-Bez.',
'Netto (€)', 'USt (%)', 'USt (€)',
'Brutto (€)', 'Buchungstext', 'Storno'
], ';');
@ -65,10 +67,11 @@ class BookingController
$this->csvSanitize((string)$row['buchungs_nr']),
$row['buchungs_datum'],
$this->csvSanitize((string)$row['beleg_nr']),
$row['soll_nr'],
$this->csvSanitize((string)$row['soll_name']),
$row['haben_nr'],
$this->csvSanitize((string)$row['haben_name']),
$row['buchungstyp'],
$row['kategorie_nr'],
$this->csvSanitize((string)$row['kategorie_name']),
$row['zahlungs_nr'],
$this->csvSanitize((string)$row['zahlungs_name']),
number_format((float)$row['betrag_netto'], 2, ',', ''),
number_format((float)$row['ust_satz'], 2, ',', ''),
number_format((float)$row['betrag_ust'], 2, ',', ''),
@ -87,15 +90,16 @@ class BookingController
$errors = [];
// Validate
if (empty($data['soll_konto_id'])) $errors['soll_konto_id'] = 'Pflichtfeld';
if (empty($data['haben_konto_id'])) $errors['haben_konto_id'] = 'Pflichtfeld';
if (empty($data['buchungstyp'])) $errors['buchungstyp'] = 'Pflichtfeld';
if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = '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'] === '')
$errors['betrag_netto'] = 'Pflichtfeld';
if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld';
if (empty($errors) && $data['soll_konto_id'] === $data['haben_konto_id']) {
$errors['haben_konto_id'] = 'Soll- und Habenkonto dürfen nicht identisch sein.';
if (empty($errors) && $data['kategorie_konto_id'] === $data['zahlungskonto_id']) {
$errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.';
}
if ($errors) {

View file

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

View file

@ -90,7 +90,7 @@ class Account
{
// Check if any bookings reference this account
$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]
);
if ($used && $used['cnt'] > 0) {

View file

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

View file

@ -8,7 +8,7 @@ use Steuerkram\Database;
/**
* 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
{
@ -20,14 +20,14 @@ class EuerCalculator
a.kontonummer,
a.bezeichnung,
a.kontenart,
SUM(CASE WHEN bk.haben_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as haben_summe,
SUM(CASE WHEN bk.soll_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as soll_summe,
bk.buchungstyp,
SUM(bk.betrag_netto) as summe_netto,
COUNT(bk.id) as buchungsanzahl
FROM accounts a
JOIN bookings bk ON (bk.haben_konto_id = a.id OR bk.soll_konto_id = a.id)
FROM bookings bk
JOIN accounts a ON bk.kategorie_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
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
ORDER BY a.kontonummer',
[$year]
@ -37,12 +37,12 @@ class EuerCalculator
$ausgaben = [];
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') {
$einnahmen[] = array_merge($row, ['netto' => abs($nettoWert)]);
} elseif ($row['kontenart'] === 'Aufwand') {
$ausgaben[] = array_merge($row, ['netto' => abs($nettoWert)]);
if ($row['buchungstyp'] === 'Einnahme') {
$einnahmen[] = array_merge($row, ['netto' => $netto]);
} elseif ($row['buchungstyp'] === 'Ausgabe') {
$ausgaben[] = array_merge($row, ['netto' => $netto]);
}
}

View file

@ -9,15 +9,12 @@ use Steuerkram\Database;
/**
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
*
* Maßgeblich sind ausschließlich 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.
* Maßgeblich sind buchungstyp, ust_satz und betrag_ust der Buchung selbst.
*
* Buchungslogik:
* Umsatzsteuer (Kz 81/86): ust_satz > 0 auf Ertrag-Haben-Konten
* Vorsteuer (Kz 61/62): ust_satz > 0 auf Aufwand/Aktiv-Soll-Konten
* Steuerfreie Umsätze (Kz 35): Ertrag-Konten mit ust_satz = 0
* Umsatzsteuer (Kz 81/86): buchungstyp = 'Einnahme' mit ust_satz > 0
* Vorsteuer (Kz 61/62): buchungstyp = 'Ausgabe' mit ust_satz > 0
* Steuerfreie Umsätze (Kz 35): buchungstyp = 'Einnahme' mit ust_satz = 0
*/
class UstvaCalculator
{
@ -42,12 +39,10 @@ class UstvaCalculator
$ustSatz = (float)$b['ust_satz'];
$betragUst = abs((float)$b['betrag_ust']);
$betragNetto = abs((float)$b['betrag_netto']);
$habenArt = $b['haben_kontenart'] ?? '';
$sollArt = $b['soll_kontenart'] ?? '';
$buchungstyp = $b['buchungstyp'];
// ── Umsatzsteuer (Ausgangsseite) ──────────────────────────────
// Kriterium: Haben-Konto = Ertrag UND ust_satz > 0
if ($habenArt === 'Ertrag' && $ustSatz > 0) {
// ── Umsatzsteuer (Einnahmen) ─────────────────────────────
if ($buchungstyp === 'Einnahme' && $ustSatz > 0) {
if (abs($ustSatz - 19) < 0.01) {
$kz['81']['betrag'] += $betragNetto;
} elseif (abs($ustSatz - 7) < 0.01) {
@ -55,16 +50,13 @@ class UstvaCalculator
}
}
// Steuerfreie Umsätze: Haben-Konto = Ertrag UND ust_satz = 0
if ($habenArt === 'Ertrag' && $ustSatz == 0) {
// Steuerfreie Umsätze: Einnahme mit ust_satz = 0
if ($buchungstyp === 'Einnahme' && $ustSatz == 0) {
$kz['35']['betrag'] += $betragNetto;
}
// ── Vorsteuer (Eingangsseite) ──────────────────────────────────
// Kriterium: Soll-Konto = Aufwand oder Aktiv (Vorsteuerkonto)
// 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) {
// ── Vorsteuer (Ausgaben) ─────────────────────────────────
if ($buchungstyp === 'Ausgabe' && $ustSatz > 0 && $betragUst > 0) {
if (abs($ustSatz - 19) < 0.01) {
$kz['61']['steuer'] += $betragUst;
} elseif (abs($ustSatz - 7) < 0.01) {
@ -92,12 +84,8 @@ class UstvaCalculator
private function fetchBookings(int $year, int $month): array
{
return Database::fetchAll(
'SELECT bk.ust_satz, bk.betrag_netto, bk.betrag_ust,
s.kontenart AS soll_kontenart,
h.kontenart AS haben_kontenart
'SELECT bk.buchungstyp, bk.ust_satz, bk.betrag_netto, bk.betrag_ust
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
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.is_reversal = 0',

View file

@ -22,7 +22,7 @@
<select id="kontenart" name="kontenart"
class="<?= isset($errors['kontenart']) ? 'input-error' : '' ?>" required>
<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' : '' ?>>
<?= $h($art) ?>
</option>

View file

@ -7,7 +7,7 @@
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
<select name="kontenart">
<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>
<?php endforeach; ?>
</select>
@ -44,10 +44,10 @@
<td>
<?php
$badgeClass = match($acc['kontenart']) {
'Ertrag' => 'badge-green',
'Aufwand' => 'badge-red',
'Aktiv' => 'badge-blue',
'Passiv' => 'badge-yellow',
'Betriebseinnahme' => 'badge-green',
'Betriebsausgabe' => 'badge-red',
'Zahlungskonto' => 'badge-blue',
'Verbindlichkeit' => 'badge-yellow',
default => 'badge-gray',
};
?>

View file

@ -149,8 +149,9 @@ $testMode = Setting::isTestMode();
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Soll</th>
<th>Haben</th>
<th>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Netto</th>
<th class="td-right">USt</th>
<th class="td-right">Brutto</th>
@ -166,8 +167,9 @@ $testMode = Setting::isTestMode();
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></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 class="td-mono text-sm"><?= $h($bk['haben_nr']) ?><br><span class="td-muted"><?= $h($bk['haben_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['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
@ -220,7 +222,7 @@ $testMode = Setting::isTestMode();
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</table>
@ -232,6 +234,17 @@ $testMode = Setting::isTestMode();
<?php
$formErrors = $formErrors ?? [];
$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-header">
@ -249,6 +262,25 @@ $testMode = Setting::isTestMode();
<?php endif; ?>
<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-group">
<label>Buchungsdatum *</label>
@ -266,28 +298,19 @@ $testMode = Setting::isTestMode();
</div>
</div>
<?php
// Konten nach Art gruppieren für <optgroup>
$kontenGruppen = [];
foreach ($accounts as $acc) {
$kontenGruppen[$acc['kontenart']][] = $acc;
}
$gruppenReihenfolge = ['Aufwand', 'Ertrag', 'Aktiv', 'Passiv', 'Neutral'];
?>
<!-- Soll-Konto -->
<!-- Kategorie-Konto -->
<div class="form-group">
<label for="soll_konto_id">Soll-Konto *
<span id="soll_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
<label for="kategorie_konto_id">Kategorie *
<span id="kategorie_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="soll_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="soll_konto_id" name="soll_konto_id" required size="6"
onchange="showKontoArt('soll_konto_id','soll_konto_art',this)">
data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="kategorie_konto_id" name="kategorie_konto_id" required size="6"
onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this)">
<option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?>
<?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Privat'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──">
<optgroup label="── <?= $h($art) ?> ──" data-kontenart="<?= $h($art) ?>">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
@ -298,20 +321,19 @@ $testMode = Setting::isTestMode();
</optgroup>
<?php endforeach; ?>
</select>
<div class="form-hint">Typisch: <strong>Aufwand</strong>-Konto (Eingangsrechnung) oder <strong>Aktiv</strong>-Konto (Forderung/Bank)</div>
</div>
<!-- Haben-Konto -->
<!-- Zahlungskonto -->
<div class="form-group">
<label for="haben_konto_id">Haben-Konto *
<span id="haben_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
<label for="zahlungskonto_id">Zahlungskonto *
<span id="zahlungskonto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="haben_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="haben_konto_id" name="haben_konto_id" required size="6"
onchange="showKontoArt('haben_konto_id','haben_konto_art',this)">
data-target="zahlungskonto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="zahlungskonto_id" name="zahlungskonto_id" required size="4"
onchange="showKontoArt('zahlungskonto_id','zahlungskonto_art',this)">
<option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?>
<?php foreach (['Zahlungskonto', 'Verbindlichkeit'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
@ -324,7 +346,6 @@ $testMode = Setting::isTestMode();
</optgroup>
<?php endforeach; ?>
</select>
<div class="form-hint">Typisch: <strong>Passiv</strong>-Konto (Verbindlichkeit) oder <strong>Ertrag</strong>-Konto (Ausgangsrechnung)</div>
</div>
<div class="form-grid form-grid-2">
@ -360,3 +381,30 @@ $testMode = Setting::isTestMode();
</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>Datum</th>
<th>Belegnr.</th>
<th>Soll-Konto</th>
<th>Haben-Konto</th>
<th>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th class="td-right">Netto</th>
<th class="td-right">USt (%)</th>
<th class="td-right">Brutto</th>
@ -63,13 +64,14 @@
</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><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm">
<?= $h($bk['soll_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['soll_name'], 0, 25, '…')) ?></span>
<?= $h($bk['kategorie_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['kategorie_name'], 0, 25, '…')) ?></span>
</td>
<td class="td-mono text-sm">
<?= $h($bk['haben_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['haben_name'], 0, 25, '…')) ?></span>
<?= $h($bk['zahlungs_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['zahlungs_name'], 0, 25, '…')) ?></span>
</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>
@ -93,7 +95,7 @@
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</table>

View file

@ -151,8 +151,9 @@ $fmt = fn($v) => View::formatMoney($v);
<tr>
<th>Datum</th>
<th>Belegnr.</th>
<th>Soll</th>
<th>Haben</th>
<th>Typ</th>
<th>Kategorie</th>
<th>Zahlungskonto</th>
<th>Buchungstext</th>
<th class="td-right">Brutto</th>
</tr>
@ -162,14 +163,15 @@ $fmt = fn($v) => View::formatMoney($v);
<tr>
<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 class="td-mono"><?= $h($b['soll_nr']) ?> <span class="text-muted">– <?= $h($b['soll_name']) ?></span></td>
<td class="td-mono"><?= $h($b['haben_nr']) ?> <span class="text-muted">– <?= $h($b['haben_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['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 class="td-right td-mono"><?= $fmt($b['betrag_brutto']) ?></td>
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</table>

View file

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

View file

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

View file

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