From 10c2817c2e8597a70e453046802ce89f33f91e05 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 25 Apr 2026 22:02:33 +0200 Subject: [PATCH 01/14] =?UTF-8?q?Fix=20#49:=20Test=2018=20pr=C3=BCft=20E?= =?UTF-8?q?=C3=9CR-Delta=20statt=20absoluten=20Saldo=20(produktionssicher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/18_StornoBuchungTest.php | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index ec56e4a..ec9294e 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -26,6 +26,17 @@ class StornoBuchungTest extends TestCase return; } + // ── EÜR: Konto-Stand VOR den Test-Buchungen ermitteln ───────── + $euerCalcPre = new \Steuerkram\Services\EuerCalculator(); + $euerPre = $euerCalcPre->calculate($year); + $kontoNettoPre = 0.0; + foreach ($euerPre['ausgaben'] as $row) { + if ((int)$row['id'] === (int)$ausgabeId) { + $kontoNettoPre = (float)$row['netto']; + break; + } + } + // ── Beleg anlegen ────────────────────────────────────────────── $belegId = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'STORNO-TEST-' . uniqid(), @@ -63,23 +74,23 @@ class StornoBuchungTest extends TestCase $this->assertEquals(-19.00, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung: betrag_ust = -19'); $this->assertEquals(-119.00, round((float)$reversal['betrag_brutto'], 2), 'Stornobuchung: betrag_brutto = -119'); - // ── EÜR: storniertes Konto hat Netto-Summe = 0 ─────────────── - $euerCalc = new \Steuerkram\Services\EuerCalculator(); - $euer = $euerCalc->calculate($year); - - $kontoNetto = null; + // ── EÜR: Storno + Original heben sich auf (Konto-Saldo unverändert) ─ + // Vergleich mit dem Stand vor den Test-Buchungen, da auf dem Produktivsystem + // bereits echte Buchungen auf dem Konto existieren können. + $euerCalc = new \Steuerkram\Services\EuerCalculator(); + $euer = $euerCalc->calculate($year); + $kontoNetto = 0.0; foreach ($euer['ausgaben'] as $row) { if ((int)$row['id'] === (int)$ausgabeId) { $kontoNetto = (float)$row['netto']; break; } } - // Wenn das Konto nicht mehr auftaucht (alle Buchungen = 0), ist das ebenfalls korrekt. - if ($kontoNetto !== null) { - $this->assertEquals(0.0, round($kontoNetto, 2), 'EÜR: Storno + Original = 0 auf Ausgabe-Konto'); - } else { - $this->assert(true, 'EÜR: Konto taucht bei Summe=0 nicht auf (HAVING buchungsanzahl > 0 greift)'); - } + $this->assertEquals( + round($kontoNettoPre, 2), + round($kontoNetto, 2), + 'EÜR: Storno + Original = 0 auf Ausgabe-Konto' + ); // ── UStVA: Vorsteuer-KZ hebt sich auf ───────────────────────── // Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61'). From 6b1cecf465c7b6ed9f53a4da36a119dd988f9012 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 10:03:10 +0200 Subject: [PATCH 02/14] Fix #51: Belegsuche auf Lieferant, Datum, Bewirtungsgrund, Teilnehmer und Brutto erweitert --- src/Models/Beleg.php | 11 ++- src/Views/belege/list.php | 2 +- src/version.txt | 2 +- tests/21_BelegSucheErweitertTest.php | 106 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 tests/21_BelegSucheErweitertTest.php diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index cbc8973..f833b8a 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -14,9 +14,14 @@ class Beleg $params = []; if (!empty($filter['search'])) { - $where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?)'; + $where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ? + OR CAST(b.beleg_datum AS CHAR) LIKE ? + OR s.name LIKE ? + OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ? + OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s + WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0) LIKE ?)'; $s = '%' . $filter['search'] . '%'; - $params = array_merge($params, [$s, $s, $s, $s]); + $params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $s]); } if (!empty($filter['year'])) { $where[] = '(YEAR(b.beleg_datum) = ? OR b.beleg_datum IS NULL)'; @@ -59,7 +64,7 @@ class Beleg $rows = Database::fetchAll($sql, $params); // Count total - $countSql = 'SELECT COUNT(*) as cnt FROM belege b WHERE ' . implode(' AND ', $where); + $countSql = 'SELECT COUNT(*) as cnt FROM belege b LEFT JOIN suppliers s ON b.supplier_id = s.id WHERE ' . implode(' AND ', $where); $countParams = array_slice($params, 0, -2); $total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0); diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index 7f30d08..268f264 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -92,7 +92,7 @@
-
+
+ + +
diff --git a/tests/23_KundenNummernkreisTest.php b/tests/23_KundenNummernkreisTest.php new file mode 100644 index 0000000..48d920f --- /dev/null +++ b/tests/23_KundenNummernkreisTest.php @@ -0,0 +1,44 @@ +assert(array_key_exists('customer_nr_prefix', $settings), 'customer_nr_prefix-Spalte existiert in company_settings'); + + // Präfix in den Einstellungen speichern und lesen + $originalPrefix = $settings['customer_nr_prefix'] ?? 'K-'; + $testPrefix = 'TEST-'; + Setting::update(array_merge($settings, ['customer_nr_prefix' => $testPrefix])); + $updated = Setting::get(); + $this->assertEquals($testPrefix, $updated['customer_nr_prefix'], 'customer_nr_prefix wird korrekt gespeichert'); + + // Kundennummer mit neuem Präfix generieren + $nr = Customer::generateKundennummer(); + $this->assert(str_starts_with($nr, $testPrefix), 'Generierte Kundennummer beginnt mit neuem Präfix'); + $numericPart = substr($nr, strlen($testPrefix)); + $this->assert(ctype_digit($numericPart), 'Numerischer Teil der Kundennummer enthält nur Ziffern'); + $this->assert((int)$numericPart > 0, 'Numerischer Teil > 0'); + + // Zurücksetzen auf Standard-Präfix + Setting::update(array_merge($updated, ['customer_nr_prefix' => 'K-'])); + $reset = Setting::get(); + $this->assertEquals('K-', $reset['customer_nr_prefix'], 'Präfix auf K- zurückgesetzt'); + + // Kundennummer mit Default-Präfix K- + $nrDefault = Customer::generateKundennummer(); + $this->assert(str_starts_with($nrDefault, 'K-'), 'Generierte Kundennummer beginnt mit K-'); + + // Originalwert wiederherstellen + Setting::update(array_merge($reset, ['customer_nr_prefix' => $originalPrefix])); + } +} From eb41c910fc8933e5eaa3ed8511659f2d668dd18e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 11:07:14 +0200 Subject: [PATCH 05/14] =?UTF-8?q?Fix=20#54:=20L=C3=B6schen-Button=20in=20R?= =?UTF-8?q?echnungs=C3=BCbersicht,=20nur=20im=20Testmodus=20sichtbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.php | 1 + src/Controllers/InvoiceController.php | 29 +++++++---- src/Models/Invoice.php | 13 +++++ src/Views/invoices/list.php | 6 +++ tests/24_RechnungLoeschenTestmodusTest.php | 56 ++++++++++++++++++++++ 5 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 tests/24_RechnungLoeschenTestmodusTest.php diff --git a/public/index.php b/public/index.php index a97660f..fef6b00 100644 --- a/public/index.php +++ b/public/index.php @@ -95,6 +95,7 @@ $router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p $router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p)); $router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p)); $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p)); +$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 556779f..686209e 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -17,19 +17,21 @@ class InvoiceController { public function index(array $params): void { - $filter = $_GET; - $page = max(1, (int)($_GET['page'] ?? 1)); - $result = Invoice::all($filter, $page); + $filter = $_GET; + $page = max(1, (int)($_GET['page'] ?? 1)); + $result = Invoice::all($filter, $page); + $settings = Setting::get(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Ausgangsrechnungen', 'content' => $this->renderView('invoices/list', [ - 'rows' => $result['rows'], - 'total' => $result['total'], - 'pages' => $result['pages'], - 'page' => $page, - 'filter' => $filter, + 'rows' => $result['rows'], + 'total' => $result['total'], + 'pages' => $result['pages'], + 'page' => $page, + 'filter' => $filter, + 'settings' => $settings, ]), 'topbarActions' => '+ Rechnung erstellen', ]); @@ -347,6 +349,17 @@ class InvoiceController return $belegNr; } + public function destroy(array $params): void + { + if (!Setting::isTestMode()) { + View::redirect('/invoices', 'Löschen ist nur im Testmodus erlaubt.', 'error'); + return; + } + $id = (int)$params['id']; + Invoice::delete($id); + View::redirect('/invoices', 'Rechnung gelöscht.'); + } + public function markPaid(array $params): void { Invoice::markPaid((int)$params['id']); diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 9dbe016..6396e0b 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -232,6 +232,19 @@ class Invoice ); } + public static function delete(int $id): void + { + $invoice = self::find($id); + if (!$invoice) return; + + if (!empty($invoice['beleg_id'])) { + Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$invoice['beleg_id']]); + Database::execute('DELETE FROM belege WHERE id = ?', [$invoice['beleg_id']]); + } + Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]); + Database::execute('DELETE FROM invoices WHERE id = ?', [$id]); + } + public static function generateRechnungsNr(): string { $settings = Database::fetchOne( diff --git a/src/Views/invoices/list.php b/src/Views/invoices/list.php index 571fd0d..3d79c5e 100644 --- a/src/Views/invoices/list.php +++ b/src/Views/invoices/list.php @@ -64,6 +64,12 @@ PDF + + + + +
diff --git a/tests/24_RechnungLoeschenTestmodusTest.php b/tests/24_RechnungLoeschenTestmodusTest.php new file mode 100644 index 0000000..7eb4b9e --- /dev/null +++ b/tests/24_RechnungLoeschenTestmodusTest.php @@ -0,0 +1,56 @@ + 'LoeschTestKunde', + 'strasse' => 'Teststr. 1', + 'plz' => '12345', + 'ort' => 'Teststadt', + 'land' => 'DE', + ]); + + $invoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Lösch-Test-Rechnung', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [ + [ + 'bezeichnung' => 'Testposition', + 'menge' => 1, + 'einheit' => 'Stk.', + 'einzelpreis' => 100, + 'ust_satz' => 19, + ], + ], + ]); + + $this->assertGreaterThan(0, $invoiceId, 'Rechnung wurde angelegt'); + $invoice = Invoice::find($invoiceId); + $this->assert($invoice !== false, 'Rechnung per find() auffindbar'); + + // Rechnung löschen + Invoice::delete($invoiceId); + + $afterDelete = Invoice::find($invoiceId); + $this->assert($afterDelete === false, 'Rechnung nach delete() nicht mehr auffindbar'); + + // Positionen ebenfalls gelöscht + $lines = Invoice::lines($invoiceId); + $this->assertEquals(0, count($lines), 'Invoice-Lines nach delete() gelöscht'); + + // test_mode-Flag ist bei Neuinstallation gesetzt + $settings = Setting::get(); + $this->assert(isset($settings['test_mode']), 'test_mode-Feld in company_settings vorhanden'); + } +} From 308895419e3aca86c2dc2ae0cd6f4c8ec53a0e7e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 12:02:21 +0200 Subject: [PATCH 06/14] =?UTF-8?q?Fix=20#54:=20L=C3=B6schreihenfolge=20korr?= =?UTF-8?q?igiert=20=E2=80=93=20invoices=20vor=20belege=20l=C3=B6schen=20(?= =?UTF-8?q?FK-Constraint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Models/Invoice.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 6396e0b..20741e0 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -237,12 +237,15 @@ class Invoice $invoice = self::find($id); if (!$invoice) return; - if (!empty($invoice['beleg_id'])) { - Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$invoice['beleg_id']]); - Database::execute('DELETE FROM belege WHERE id = ?', [$invoice['beleg_id']]); - } + $belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null; + Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]); Database::execute('DELETE FROM invoices WHERE id = ?', [$id]); + + if ($belegId !== null) { + Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]); + Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]); + } } public static function generateRechnungsNr(): string From 7de93f14b8f5aba3a037d072927a7c00f8dabbc1 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 12:02:25 +0200 Subject: [PATCH 07/14] Fix #51: Dezimalkomma in Brutto-Suche wird in Dezimalpunkt umgewandelt --- src/Models/Beleg.php | 3 ++- tests/21_BelegSucheErweitertTest.php | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index f833b8a..4004430 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -21,7 +21,8 @@ class Beleg OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0) LIKE ?)'; $s = '%' . $filter['search'] . '%'; - $params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $s]); + $sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%'; + $params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]); } if (!empty($filter['year'])) { $where[] = '(YEAR(b.beleg_datum) = ? OR b.beleg_datum IS NULL)'; diff --git a/tests/21_BelegSucheErweitertTest.php b/tests/21_BelegSucheErweitertTest.php index 92b60c6..032b502 100644 --- a/tests/21_BelegSucheErweitertTest.php +++ b/tests/21_BelegSucheErweitertTest.php @@ -88,7 +88,11 @@ class BelegSucheErweitertTest extends TestCase // 299 * 1.19 = 355.81 $res = \Steuerkram\Models\Beleg::all(['search' => '355.81'], 1, 50); $found = $this->containsBeleg($res['rows'], $belegId); - $this->assert($found, 'Suche nach Bruttobetrag findet den Beleg'); + $this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalpunkt)'); + + $res = \Steuerkram\Models\Beleg::all(['search' => '355,81'], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalkomma)'); // ── Bestehende Felder funktionieren noch ────────────────────────── $res = \Steuerkram\Models\Beleg::all(['search' => $belegNr], 1, 50); From 491f5c6dc57433120047cf1089f394dcd49c9adf Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 13:03:11 +0200 Subject: [PATCH 08/14] Fix #56: Leistungszeitraum als freies Textfeld (von/bis-Felder entfernt) --- Changes.md | 12 ++++++++++++ migrations/034_leistungszeitraum_text.sql | 23 +++++++++++++++++++++++ src/Controllers/InvoiceController.php | 4 +--- src/Models/Invoice.php | 12 +++++------- src/Services/PdfInvoiceService.php | 9 +-------- src/Views/invoices/form.php | 13 ++++--------- src/Views/invoices/show.php | 17 +---------------- 7 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 migrations/034_leistungszeitraum_text.sql diff --git a/Changes.md b/Changes.md index b13f9e7..46b3527 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-04-26 – Leistungszeitraum als freies Textfeld + +### Geändert +- `migrations/034_leistungszeitraum_text.sql`: Spalten `leistungszeitraum_von` (DATE) und `leistungszeitraum_bis` (DATE) wurden durch eine einzige Textspalte `leistungszeitraum` (VARCHAR 255) ersetzt; bestehende Datumswerte werden automatisch migriert +- `src/Views/invoices/form.php`: Zwei Datumsfelder durch ein Freitextfeld „Leistungszeitraum" ersetzt, Grid von 4 auf 3 Spalten angepasst +- `src/Models/Invoice.php`: INSERT/UPDATE verwenden jetzt `leistungszeitraum` statt `leistungszeitraum_von`/`leistungszeitraum_bis` +- `src/Controllers/InvoiceController.php`: Datums-Defaults für `leistungszeitraum_von`/`leistungszeitraum_bis` entfernt +- `src/Views/invoices/show.php`: Leistungszeitraum-Anzeige vereinfacht (kein Datumsformat, direkter Textausgabe) +- `src/Services/PdfInvoiceService.php`: Leistungszeitraum-Ausgabe vereinfacht + +--- + ## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt ### Geändert diff --git a/migrations/034_leistungszeitraum_text.sql b/migrations/034_leistungszeitraum_text.sql new file mode 100644 index 0000000..776a351 --- /dev/null +++ b/migrations/034_leistungszeitraum_text.sql @@ -0,0 +1,23 @@ +-- Leistungszeitraum: von zwei DATE-Feldern zu einem freien Textfeld +ALTER TABLE invoices + ADD COLUMN leistungszeitraum VARCHAR(255) NULL AFTER leistungszeitraum_bis; + +-- Bestehende Daten migrieren: Datumsfelder als lesbaren Text zusammenführen +UPDATE invoices +SET leistungszeitraum = CASE + WHEN leistungszeitraum_von IS NOT NULL AND leistungszeitraum_bis IS NOT NULL + AND leistungszeitraum_von != leistungszeitraum_bis + THEN CONCAT( + DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y'), + ' – ', + DATE_FORMAT(leistungszeitraum_bis, '%d.%m.%Y') + ) + WHEN leistungszeitraum_von IS NOT NULL + THEN DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y') + ELSE NULL +END +WHERE leistungszeitraum_von IS NOT NULL OR leistungszeitraum_bis IS NOT NULL; + +ALTER TABLE invoices + DROP COLUMN leistungszeitraum_von, + DROP COLUMN leistungszeitraum_bis; diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 686209e..2eab9b9 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -43,9 +43,7 @@ class InvoiceController $settings = Setting::get(); $invoiceDefaults = [ - 'rechnungs_datum' => date('Y-m-d'), - 'leistungszeitraum_von' => date('Y-m-01'), - 'leistungszeitraum_bis' => date('Y-m-t'), + 'rechnungs_datum' => date('Y-m-d'), ]; $preCustomerId = (int)($_GET['customer_id'] ?? 0); diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 20741e0..0fd90d7 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -78,14 +78,13 @@ class Invoice $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( - 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum_von, leistungszeitraum_bis, + 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum, status, notes) - VALUES (?, ?, ?, ?, ?, ?, \'Entwurf\', ?)', + VALUES (?, ?, ?, ?, ?, \'Entwurf\', ?)', [ (int)$data['customer_id'], $data['betreff'], - $data['leistungszeitraum_von'] ?: null, - $data['leistungszeitraum_bis'] ?: null, + ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, @@ -116,14 +115,13 @@ class Invoice $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( - 'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum_von=?, leistungszeitraum_bis=?, + 'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum=?, rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW() WHERE id=? AND status=\'Entwurf\'', [ (int)$data['customer_id'], $data['betreff'], - $data['leistungszeitraum_von'] ?: null, - $data['leistungszeitraum_bis'] ?: null, + ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 718c311..1b02913 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -68,14 +68,7 @@ class PdfInvoiceService $logoWrapStyle = 'text-align:center'; } - // Leistungszeitraum - $lz = ''; - if (!empty($inv['leistungszeitraum_von'])) { - $lz = $fmtDate($inv['leistungszeitraum_von']); - if (!empty($inv['leistungszeitraum_bis']) && $inv['leistungszeitraum_bis'] !== $inv['leistungszeitraum_von']) { - $lz .= ' – ' . $fmtDate($inv['leistungszeitraum_bis']); - } - } + $lz = $inv['leistungszeitraum'] ?? ''; // Fälligkeitsdatum $fälligText = !empty($inv['faellig_datum']) diff --git a/src/Views/invoices/form.php b/src/Views/invoices/form.php index ee88763..f30a8a4 100644 --- a/src/Views/invoices/form.php +++ b/src/Views/invoices/form.php @@ -37,21 +37,16 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0); -
+
- - -
-
- - + +
diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 046ca38..eb7a876 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -7,22 +7,7 @@ $customerName = !empty($invoice['firma']) ? $invoice['firma'] : trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']); -$lz = ''; -if (!empty($invoice['leistungszeitraum_von'])) { - $lz = View::formatDate($invoice['leistungszeitraum_von']); - if (!empty($invoice['leistungszeitraum_bis'])) { - $lz .= ' – ' . View::formatDate($invoice['leistungszeitraum_bis']); - } -} - -// Leistungszeitraum mit
für die Vorschau-Tabelle (Datumsangaben untereinander) -$lzHtml = ''; -if (!empty($invoice['leistungszeitraum_von'])) { - $lzHtml = $h(View::formatDate($invoice['leistungszeitraum_von'])); - if (!empty($invoice['leistungszeitraum_bis']) && $invoice['leistungszeitraum_bis'] !== $invoice['leistungszeitraum_von']) { - $lzHtml .= '
' . $h(View::formatDate($invoice['leistungszeitraum_bis'])); - } -} +$lzHtml = $h($invoice['leistungszeitraum'] ?? ''); $badgeClass = match($invoice['status']) { 'Bezahlt' => 'badge-green', From 02463e40d4a08f3667a8aabe009725a1920f5e0f Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 13:07:04 +0200 Subject: [PATCH 09/14] =?UTF-8?q?Fix=20#55:=20Ollama-Erreichbarkeit=20akti?= =?UTF-8?q?v=20pr=C3=BCfen,=20Fehler=20ins=20PHP-Log=20schreiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 8 ++++++++ src/Services/LlmService.php | 24 ++++++++++++++++++++++-- src/Views/settings/form.php | 18 +++++++++++++----- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/Changes.md b/Changes.md index 46b3527..d6ce612 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama + +### Geändert +- `src/Services/LlmService.php`: Neue Methode `isReachable()` für Diagnosezwecke; Fehlerlog in `callOllama()` bei cURL-Fehler oder leerer Antwort +- `src/Views/settings/form.php`: Einstellungsseite prüft jetzt aktiv, ob der Ollama-Server erreichbar ist (nicht nur ob er konfiguriert ist); zeigt bei Verbindungsfehler einen konkreten Hinweis auf `OLLAMA_HOST=0.0.0.0:11434` + +--- + ## 2026-04-26 – Leistungszeitraum als freies Textfeld ### Geändert diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index 22b9007..f144826 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -93,6 +93,17 @@ class LlmService return $data['message']['content'] ?? ''; } + /** + * Prüft ohne Timeout-Kosten, ob der Provider überhaupt erreichbar ist. + * Nur für Diagnosezwecke (Einstellungsseite) – nicht im normalen Request-Pfad nutzen. + */ + public function isReachable(): bool + { + if ($this->mode === null) return false; + if ($this->mode === 'ollama') return $this->probeOllama($this->url); + return true; // OpenAI-Modus: kein schneller Probe verfügbar + } + private function detect(): void { $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); @@ -147,7 +158,8 @@ class LlmService 'stream' => false, ]; - $ch = curl_init($this->url . '/api/generate'); + $endpoint = $this->url . '/api/generate'; + $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, @@ -157,9 +169,17 @@ class LlmService ]); $response = curl_exec($ch); $err = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($err || !$response) return ''; + if ($err) { + error_log("[Steuerkram] LlmService::callOllama() cURL-Fehler auf {$endpoint}: {$err}"); + return ''; + } + if (!$response) { + error_log("[Steuerkram] LlmService::callOllama() leere Antwort von {$endpoint} (HTTP {$httpCode})"); + return ''; + } return json_decode($response, true)['response'] ?? ''; } diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 96b5e3b..22a0f8e 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -140,14 +140,22 @@ Konfiguration via .env (lokales Ollama oder Remote-API).

getProviderLabel(); + $reachable = $providerLabel !== null ? $llm->isReachable() : false; + $ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); ?> -

- - ✓ Provider erkannt: +

+ + ✓ Provider erreichbar: + + ✗ Provider konfiguriert (), aber Server nicht erreichbar. + Prüfe ob Ollama auf dem Host läuft und auf allen Interfaces hört (OLLAMA_HOST=0.0.0.0:11434). + Details im PHP-Fehlerlog des Webservers (docker compose logs web). + + ✗ OLLAMA_URL gesetzt (), aber kein Provider erkannt. - — Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen) + — Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen)

From 0866344f515e55fcd78f220a285895b0e73e6ab6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 17:02:28 +0200 Subject: [PATCH 10/14] =?UTF-8?q?Fix=20#34:=20Nach=20Buchung=20auf=20Anlag?= =?UTF-8?q?everm=C3=B6gens-Konto=20direkt=20zur=20Asset-Bearbeitung=20weit?= =?UTF-8?q?erleiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Controllers/BookingController.php | 8 ++- tests/25_AnlageAutoRedirectTest.php | 82 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/25_AnlageAutoRedirectTest.php diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index 9712934..abcd054 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -144,6 +144,7 @@ class BookingController ); $kontonummer = $konto['kontonummer'] ?? ''; + $newAssetId = null; if (($konto['kontenart'] ?? '') === 'Anlagevermögen') { $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; if ($assetId === null) { @@ -154,6 +155,7 @@ class BookingController 'anschaffungsdatum' => $data['buchungs_datum'], 'anschaffungskosten' => (float)$data['betrag_netto'], ]); + $newAssetId = $assetId; } $data['asset_id'] = $assetId; } @@ -180,7 +182,11 @@ class BookingController } } - View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); + if ($newAssetId !== null) { + View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.'); + } else { + View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); + } } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage()); $this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data); diff --git a/tests/25_AnlageAutoRedirectTest.php b/tests/25_AnlageAutoRedirectTest.php new file mode 100644 index 0000000..56ebeb0 --- /dev/null +++ b/tests/25_AnlageAutoRedirectTest.php @@ -0,0 +1,82 @@ +assertNotEmpty($anlageKonto, 'Anlagevermögens-Konto vorhanden'); + + $bankKonto = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 LIMIT 1" + ); + $this->assertNotEmpty($bankKonto, 'Zahlungskonto vorhanden'); + + if (!$anlageKonto || !$bankKonto) { + return; + } + + // Beleg anlegen + $belegNr = 'ANLAGE-TEST-' . uniqid(); + $datum = date('Y-m-d'); + $belegId = Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'anlage-test.pdf', + 'storage_path' => 'static/uploads/test/anlage-test.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => $datum, + 'rechnungs_nr' => 'RE-ANLAGE-001', + 'betrag_brutto' => 1190.00, + 'belegtyp' => 'Eingangsrechnung', + ]); + $this->assertGreaterThan(0, $belegId, 'Beleg angelegt'); + + // Buchung auf Anlagevermögens-Konto, kein bestehendes Asset + $buchungId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => (int)$anlageKonto['id'], + 'zahlungskonto_id' => (int)$bankKonto['id'], + 'betrag_netto' => 1000.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test-Notebook', + 'asset_id' => null, + ]); + $this->assertGreaterThan(0, $buchungId, 'Buchung auf Anlagekonto angelegt'); + + // Neues Asset muss automatisch existieren und mit Buchungstext befüllt sein + $buchung = Booking::find($buchungId); + $this->assertNotEmpty($buchung, 'Buchung abrufbar'); + + // In der Praxis legt BookingController das Asset an und setzt asset_id. + // Hier testen wir, dass Asset::create() korrekt einen Datensatz erzeugt, + // der die Buchungsdaten spiegelt – so wie BookingController es tut. + $assetId = Asset::create([ + 'bezeichnung' => 'Test-Notebook', + 'account_id' => (int)$anlageKonto['id'], + 'anschaffungsdatum' => $datum, + 'anschaffungskosten' => 1000.00, + ]); + $this->assertGreaterThan(0, $assetId, 'Asset wird aus Buchungsdaten angelegt (ID > 0)'); + + $asset = Asset::find($assetId); + $this->assertNotEmpty($asset, 'Asset abrufbar'); + $this->assertEquals('Test-Notebook', $asset['bezeichnung'], 'Bezeichnung = Buchungstext'); + $this->assertEquals($datum, $asset['anschaffungsdatum'], 'Anschaffungsdatum = Buchungsdatum'); + $this->assertEquals(1000.00, (float)$asset['anschaffungskosten'], 'Anschaffungskosten = Nettobetrag'); + $this->assertEquals((int)$anlageKonto['id'], (int)$asset['account_id'], 'Account-ID verknüpft'); + } +} From bbfb41232e0c4c4f797aaf5a0d6247d76df9ac7e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 17:12:40 +0200 Subject: [PATCH 11/14] Fix: UStVA-Kennziffern auf offizielle ELSTER-Definitionen korrigiert --- Changes.md | 12 ++ docs/berechnungslogik.md | 41 +++---- src/Controllers/UstvaController.php | 2 +- src/Services/ElsterXmlService.php | 16 +-- src/Services/UstvaCalculator.php | 163 +++++++++++++--------------- src/Views/tax/ustva_kz_bookings.php | 10 +- src/Views/tax/ustva_show.php | 19 ++-- tests/05_TaxSummenTest.php | 27 +---- tests/07_UstvaAuslandTest.php | 64 +++++------ tests/13_ElsterXmlKz39Test.php | 28 +++-- tests/18_StornoBuchungTest.php | 6 +- 11 files changed, 172 insertions(+), 216 deletions(-) diff --git a/Changes.md b/Changes.md index d6ce612..5c5092c 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-04-26 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert + +### Geändert +- `src/Services/UstvaCalculator.php`: Kz 66 ist jetzt die primäre Inlandsvorsteuer (§§ 14, 14a) – alle Ausgabe-Buchungen mit Vorsteuer fließen direkt in Kz 66 (buchungsbasiert, unabhängig vom USt-Satz). Kz 61 ist jetzt Vorsteuer aus ig. Erwerben (§ 3d) = Kz89×0,19 + Kz93×0,07. Kz 62 (Einfuhrumsatzsteuer) und Kz 67 (§ 13b) werden auf 0 gesetzt. Kz 83 ist jetzt das Ergebnis (Verbleibende Vorauszahlung / Überschuss) statt der USt auf 19 %-Umsätze. Kz 35/44 (veraltet seit Migration 017) und Kz 91/94 (wurden fälschlich als USt-Beträge auf ig. Erwerb genutzt) entfernt. Kz 43 (Ausfuhren) und Kz 48 (steuerfreie Umsätze ohne VSt-Abzug) hinzugefügt. +- `src/Services/ElsterXmlService.php`: Betragsfelder auf korrekte Kz aktualisiert (43/48 statt 35/44); Kz39-Export (Sondervorauszahlung) durch Kz83-Export (Vorauszahlung/Überschuss) ersetzt. +- `src/Views/tax/ustva_show.php`: Abschnitte und Beschriftungen auf korrekte ELSTER-Kennziffern angepasst. +- `src/Views/tax/ustva_kz_bookings.php`: Referenz auf Kz 69 → Kz 83 umgestellt. +- `src/Controllers/UstvaController.php`: Whitelist gültiger Kennziffern aktualisiert. +- `docs/berechnungslogik.md`: UStVA-Abschnitt vollständig neu beschrieben. + +--- + ## 2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama ### Geändert diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index 9d4cd07..d6b4bb5 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -43,44 +43,31 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d Die UStVA kombiniert drei Quellen: -**Quelle 1 – kontobasiert** (`accounts.ustva_kz`): reguläre Umsätze. Buchungen mit EU-Steuerarten werden hier ausgeschlossen. Kz 61/62 sind aus dieser Quelle entfernt – `ustva_kz='61'/'62'` wird ignoriert. +**Quelle 1 – kontobasiert** (`accounts.ustva_kz`): Umsatz- und steuerfreie Konten. Buchungen mit EU-Steuerarten werden hier ausgeschlossen. -**Quelle 2 – buchungsbasiert** (`bookings.ust_satz`): Vorsteuer Kz 61/62. Alle Ausgabe-Buchungen mit `betrag_ust != 0` fließen anhand ihres `ust_satz` direkt in Kz 61 (19 %) bzw. Kz 62 (7 %) ein, unabhängig davon welches Konto gebucht wurde. Buchungen mit EU-Steuerarten sind ausgenommen. +**Quelle 2 – buchungsbasiert** (`buchungstyp='Ausgabe'`, `betrag_ust != 0`): Alle inländischen Eingangsrechnungen mit Vorsteuer → **Kz 66** (§§ 14, 14a UStG). Buchungen mit EU-Steuerarten sind ausgenommen. Der Wert `ustva_kz` auf Vorsteuer-Konten wird nicht ausgewertet. **Quelle 3 – steuerartbasiert** (`bookings.steuerart`): EU-Sonderfälle (ig. Lieferung, Ausfuhr, ig. Erwerb). Diese Buchungen landen unabhängig vom Kategorie-Konto in den richtigen Kennzahlen. ### Kontozuordnung und Kennzahlen -| Kz | Bezeichnung | Quelle | Erläuterung | +| Kz | ELSTER-Bezeichnung | Quelle | Erläuterung | |---|---|---|---| | **81** | Steuerpfl. Umsätze 19 % | `SUM(betrag_netto)` WHERE `ustva_kz='81'` | 8400, 8570, 8720, 8801, 8820, 8922 | -| **83** | USt auf steuerpfl. Umsätze 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='81'` | s.o. | | **86** | Steuerpfl. Umsätze 7 % | `SUM(betrag_netto)` WHERE `ustva_kz='86'` | 8300, 8710, 8921 | -| **84** | USt auf steuerpfl. Umsätze 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='86'` | s.o. | -| **35** | Steuerfreie Umsätze | `SUM(betrag_netto)` WHERE `ustva_kz='35'` | 8100, 8105, 8110 | -| **41** | Steuerfreie ig. Lieferungen | kontobasiert (`ustva_kz='41'`) **+** `steuerart='ig_lieferung'` | 8125; oder Buchung mit steuerart | -| **44** | Steuerfreie Ausfuhren | kontobasiert (`ustva_kz='44'`) **+** `steuerart='export'` | 8120; oder Buchung mit steuerart | -| **61** | Vorsteuer 19 % | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND ROUND(ust_satz)=19` | buchungsbasiert – kein spezielles VSt19-Konto nötig | -| **62** | Vorsteuer 7 % | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND ROUND(ust_satz)=7` | buchungsbasiert – kein spezielles VSt7-Konto nötig | +| **41** | Steuerfreie ig. Lieferungen (§ 4 Nr. 1b) | kontobasiert (`ustva_kz='41'`) **+** `steuerart='ig_lieferung'` | 8125; oder Buchung mit steuerart | +| **43** | Steuerfreie Ausfuhren (§ 4 Nr. 1a) | kontobasiert (`ustva_kz='43'`) **+** `steuerart='export'` | 8120; oder Buchung mit steuerart | +| **48** | Steuerfreie Umsätze ohne Vorsteuerabzug | `SUM(betrag_netto)` WHERE `ustva_kz='48'` | 8100, 8105, 8110 | | **89** | Ig. Erwerb 19 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_19'` | kein spezielles Konto nötig | -| **91** | USt auf ig. Erwerb 19 % | `Kz 89 × 0,19` | berechnet | | **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_7'` | kein spezielles Konto nötig | -| **94** | USt auf ig. Erwerb 7 % | `Kz 93 × 0,07` | berechnet | +| **66** | Vorsteuer aus Lieferungen/Leistungen (§§ 14, 14a) | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND betrag_ust != 0` | buchungsbasiert – alle inländischen VSt-Beträge unabhängig vom Satz | +| **61** | Vorsteuer aus ig. Erwerben (§ 3d) | `Kz 89 × 0,19 + Kz 93 × 0,07` | berechnet; ig. Erwerb ist USt-neutral (hebt sich in Kz 83 auf) | +| **62** | Vorsteuer aus Einfuhrumsatzsteuer | 0 | in diesem System nicht vorgesehen | +| **67** | Vorsteuer aus Leistungen nach § 13b | 0 | in diesem System nicht vorgesehen | +| **83** | Verbleibende Umsatzsteuer-Vorauszahlung / Überschuss | `ust19 + ust7 − Kz 66 − Kz 61` | Ergebnis; ust19/ust7 = intern aus `betrag_ust` von 81/86-Konten | -### Abgeleitete Kennzahlen +### Kennziffern-Bedeutung (ELSTER) -| Kz | Bezeichnung | Berechnung | -|---|---|---| -| **67** | Vorsteuer aus ig. Erwerb | Kz 91 + Kz 94 | -| **66** | Vorsteuerbeträge gesamt | Kz 61 + Kz 62 + Kz 67 | -| **69** | Verbleibende USt-Vorauszahlung / Erstattung | (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 | +Kz 83 ist laut ELSTER-Hilfe das **Ergebnis der Voranmeldung** (Verbleibende Vorauszahlung oder Überschuss), nicht – wie in früheren Systemversionen – die USt auf 19 %-Umsätze. Die ELSTER-Kennziffern 83, 61, 62, 66, 67 wurden mit dieser Änderung (2026-04-26) auf die offiziellen ELSTER-Definitionen korrigiert. -### Migration 022 – Vorsteuer-Kz-Aufteilung (historisch) - -Vor Migration 022 trugen alle Vorsteuer-Konten `ustva_kz='66'`. Migration 022 setzte `ustva_kz='61'/'62'` je nach `steuercode`. Diese kontobasierte Zuordnung war erforderlich, weil alle Ausgaben eines Kontos denselben USt-Satz hatten. - -### Buchungsbasierte Vorsteuer (ab 2026) - -Die kontobasierte Zuordnung über `ustva_kz='61'/'62'` wurde zugunsten einer buchungsbasierten Zuordnung aufgegeben: `UstvaCalculator` wertet jetzt direkt `bookings.ust_satz` aus (`buchungstyp='Ausgabe'`, `betrag_ust != 0`). Damit reicht ein einzelnes Aufwandskonto für 19 % und 7 % USt aus. Der Wert `ustva_kz='61'/'62'` auf Konten wird nicht mehr ausgewertet. - -Kz 66 wird weiterhin rechnerisch als `Kz 61 + Kz 62 + Kz 67` aggregiert. +Kz 35 und Kz 44 wurden in Migration 017 auf Kz 48 (steuerfreie Umsätze ohne VSt-Abzug) bzw. Kz 43 (Ausfuhren) umgestellt und werden nicht mehr verwendet. diff --git a/src/Controllers/UstvaController.php b/src/Controllers/UstvaController.php index 059c174..1cc29eb 100644 --- a/src/Controllers/UstvaController.php +++ b/src/Controllers/UstvaController.php @@ -104,7 +104,7 @@ class UstvaController $month = (int)$params['month']; $kz = $params['kz'] ?? ''; - $validKz = ['81','83','86','84','35','41','44','89','91','93','94','61','62','67','66','69']; + $validKz = ['81','86','41','43','48','89','93','66','61','62','67','83']; if (!in_array($kz, $validKz, true)) { View::redirect("/tax/ustva/{$year}/{$month}", 'Ungültige Kennzahl.', 'error'); return; diff --git a/src/Services/ElsterXmlService.php b/src/Services/ElsterXmlService.php index d61f249..54d2033 100644 --- a/src/Services/ElsterXmlService.php +++ b/src/Services/ElsterXmlService.php @@ -114,12 +114,12 @@ class ElsterXmlService $ustva->appendChild($dom->createElement('Steuernummer', $steuernummer)); // Kz-Felder: Bemessungsgrundlagen ganzzahlig, Steuerbeträge mit 2 Dezimalstellen. - // Kz83/Kz84 werden nicht übertragen (serverseitig aus Kz81/Kz86 abgeleitet). - // Kz69 (Zahllast) wird als Kz39 übertragen. + // Kz83 (Vorauszahlung/Überschuss) wird aus den Komponenten von ELSTER berechnet, + // muss aber dennoch mit übertragen werden. $kzBetrag = fn(float $v): string => (string)(int)round($v); $kzSteuer = fn(float $v): string => number_format($v, 2, '.', ''); - $betragsfelder = ['81', '86', '35', '41', '44', '89', '93']; + $betragsfelder = ['81', '86', '41', '43', '48', '89', '93']; foreach ($betragsfelder as $nr) { $val = (float)($kz[$nr]['betrag'] ?? 0.0); if ($val != 0.0) { @@ -127,7 +127,7 @@ class ElsterXmlService } } - $steuerfelder = ['61', '62', '67', '66']; + $steuerfelder = ['66', '61', '62', '67']; foreach ($steuerfelder as $nr) { $val = (float)($kz[$nr]['steuer'] ?? 0.0); if ($val != 0.0) { @@ -135,10 +135,10 @@ class ElsterXmlService } } - // Zahllast → Kz39 (nur bei positivem Wert; bei Erstattung weglassen, da kein Vorzeichen erlaubt) - $zahllast = (float)($kz['69']['steuer'] ?? 0.0); - if ($zahllast > 0.0) { - $ustva->appendChild($dom->createElement('Kz39', $kzSteuer($zahllast))); + // Kz83: Verbleibende Umsatzsteuer-Vorauszahlung (positiv) / Überschuss (negativ) + $kz83val = (float)($kz['83']['steuer'] ?? 0.0); + if ($kz83val != 0.0) { + $ustva->appendChild($dom->createElement('Kz83', $kzSteuer($kz83val))); } return $dom->saveXML(); diff --git a/src/Services/UstvaCalculator.php b/src/Services/UstvaCalculator.php index 6349af8..ad9c253 100644 --- a/src/Services/UstvaCalculator.php +++ b/src/Services/UstvaCalculator.php @@ -9,76 +9,72 @@ use Steuerkram\Database; /** * Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung. * - * Zwei Quellen fließen in die Berechnung ein: + * Drei Quellen fließen in die Berechnung ein: * * 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos): - * ustva_kz = '81' → Kz 81 (betrag_netto), Kz 83 (betrag_ust) - * ustva_kz = '86' → Kz 86 (betrag_netto), Kz 84 (betrag_ust) - * ustva_kz = '35' → Kz 35 (betrag_netto) + * ustva_kz = '81' → Kz 81 (betrag_netto); intern: ust19 (betrag_ust) + * ustva_kz = '86' → Kz 86 (betrag_netto); intern: ust7 (betrag_ust) * ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto] - * ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto] - * (Kz 61/62 werden NICHT mehr über ustva_kz geroutet) + * ustva_kz = '43' → Kz 43 (betrag_netto) [steuerfreie Ausfuhren § 4 Nr. 1a] + * ustva_kz = '48' → Kz 48 (betrag_netto) [steuerfreie Umsätze ohne Vorsteuerabzug] * - * 2. Buchungsbasiert (bookings.ust_satz, für Vorsteuer): - * buchungstyp = 'Ausgabe', ust_satz = 19 → Kz 61 (betrag_ust) - * buchungstyp = 'Ausgabe', ust_satz = 7 → Kz 62 (betrag_ust) - * Damit entfällt die Notwendigkeit separater VSt19/VSt7-Konten. + * 2. Buchungsbasiert (buchungstyp='Ausgabe', betrag_ust ≠ 0): + * Alle inländischen Eingangsrechnungen → Kz 66 (§§ 14, 14a UStG) * * 3. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto): * steuerart = 'ig_lieferung' → Kz 41 (betrag_netto) - * steuerart = 'export' → Kz 44 (betrag_netto) - * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto), Kz 91 (netto × 0,19) - * steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto), Kz 94 (netto × 0,07) + * steuerart = 'export' → Kz 43 (betrag_netto) + * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto) + * steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto) * Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung * ausgeschlossen, um Doppelzählung zu vermeiden. - * Buchungen mit EU-Steuerarten fließen ebenfalls NICHT in Kz 61/62. * * Abgeleitete Kennzahlen: - * Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb) - * Kz 66 = Kz 61 + Kz 62 + Kz 67 (Vorsteuer gesamt) - * Kz 69 = (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 (Zahllast) + * Kz 61 = Kz89 × 0,19 + Kz93 × 0,07 (Vorsteuer aus ig. Erwerben, § 3d UStG) + * Kz 62 = 0 (Einfuhrumsatzsteuer – in diesem System n.v.) + * Kz 67 = 0 (§ 13b-Vorsteuer – in diesem System n.v.) + * Kz 83 = ust19 + ust7 − Kz66 − Kz61 − Kz62 − Kz67 + * = ust19 + ust7 − Kz66 (ig. Erwerb ist USt-neutral) */ class UstvaCalculator { /** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */ private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7']; - /** Vorsteuer-Kennzahl je USt-Satz (buchungsbasiert). */ - private const VST_SATZ_KZ = [19 => '61', 7 => '62']; - /** - * Welche accounts.ustva_kz-Werte liefern Buchungen für die Drill-down-Ansicht. - * Nur für den kontobasierten Teil; EU-Steuerarten kommen aus KZ_STEUERART_MAP. - * Kz 61/62 sind hier nicht mehr enthalten (buchungsbasiert via VST_SATZ_KZ). + * Kontobasiertes Drill-down-Mapping. + * Kz 83 (Zahllast) enthält auch die Einnahmen-Konten 81/86. */ private const KZ_ACCOUNT_MAP = [ '81' => ['81'], - '83' => ['81'], '86' => ['86'], - '84' => ['86'], - '35' => ['35'], '41' => ['41'], - '44' => ['44'], + '43' => ['43'], + '48' => ['48'], + '83' => ['81', '86'], ]; /** - * Welche steuerart-Werte liefern Buchungen für eine Kennzahl. + * Steuerart-basiertes Drill-down-Mapping. */ private const KZ_STEUERART_MAP = [ '41' => ['ig_lieferung'], - '44' => ['export'], + '43' => ['export'], '89' => ['ig_erwerb_19'], - '91' => ['ig_erwerb_19'], '93' => ['ig_erwerb_7'], - '94' => ['ig_erwerb_7'], - '67' => ['ig_erwerb_19', 'ig_erwerb_7'], - '66' => ['ig_erwerb_19', 'ig_erwerb_7'], - '69' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'], + '61' => ['ig_erwerb_19', 'ig_erwerb_7'], + '83' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'], ]; + /** + * Kennzahlen, deren Drill-down alle inländischen Ausgaben mit Vorsteuer enthält. + * (buchungsbasiert: buchungstyp='Ausgabe', betrag_ust != 0, keine EU-Steuerarten) + */ + private const KZ_INCLUDES_VST = ['66', '83']; + public function calculate(int $year, int $month): array { - // ── 1. Kontobasierte Kennzahlen (EU-Steuerarten und Kz 61/62 ausgeschlossen) ── + // ── 1. Kontobasierte Kennzahlen (EU-Steuerarten ausgeschlossen) ────────── $euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?')); $accountRows = Database::fetchAll( 'SELECT a.ustva_kz, @@ -89,7 +85,6 @@ class UstvaCalculator JOIN accounts a ON a.id = bk.kategorie_konto_id WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? AND a.ustva_kz IS NOT NULL AND a.ustva_kz != \'\' - AND a.ustva_kz NOT IN (\'61\', \'62\') AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . ')) GROUP BY a.ustva_kz', array_merge([$year, $month], self::EU_STEUERARTEN) @@ -103,26 +98,19 @@ class UstvaCalculator ]; } - // ── 2. Buchungsbasierte Vorsteuer Kz 61/62 (via ust_satz) ──────────── - $vstRows = Database::fetchAll( - 'SELECT bk.ust_satz, SUM(bk.betrag_ust) AS summe_ust + // ── 2. Buchungsbasierte Vorsteuer Kz 66 (alle inländischen Eingangsrechnungen) ── + $vstRow = Database::fetchOne( + 'SELECT SUM(bk.betrag_ust) AS summe_ust FROM bookings bk JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? AND bk.buchungstyp = \'Ausgabe\' AND bk.betrag_ust != 0 - AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . ')) - GROUP BY bk.ust_satz', + AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))', array_merge([$year, $month], self::EU_STEUERARTEN) ); - foreach ($vstRows as $row) { - $satz = (int)round((float)$row['ust_satz']); - $kzNr = self::VST_SATZ_KZ[$satz] ?? null; - if ($kzNr !== null) { - $byKz[$kzNr] = ['netto' => 0.0, 'ust' => (float)$row['summe_ust']]; - } - } + $byKz['66'] = ['netto' => 0.0, 'ust' => (float)($vstRow['summe_ust'] ?? 0.0)]; // ── 3. Steuerart-basierte Kennzahlen ───────────────────────────────── $stRows = Database::fetchAll( @@ -141,42 +129,41 @@ class UstvaCalculator } // ── 4. Kennzahlen zusammensetzen ───────────────────────────────────── - $n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0); - $u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0); - $st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0); + $n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0); + $u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0); + $st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0); - $kz81 = $n('81'); $kz83 = $u('81'); - $kz86 = $n('86'); $kz84 = $u('86'); - $kz35 = $n('35'); - $kz41 = $n('41') + $st('ig_lieferung'); - $kz44 = $n('44') + $st('export'); - $kz89 = $st('ig_erwerb_19'); - $kz91 = round($kz89 * 0.19, 2); - $kz93 = $st('ig_erwerb_7'); - $kz94 = round($kz93 * 0.07, 2); - $kz61 = $u('61'); - $kz62 = $u('62'); - $kz67 = $kz91 + $kz94; - $kz66 = $kz61 + $kz62 + $kz67; - $kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66; + $kz81 = $n('81'); + $ust19 = $u('81'); // interne USt aus 19 %-Umsätzen (kein eigenständiges ELSTER-Kz) + $kz86 = $n('86'); + $ust7 = $u('86'); // interne USt aus 7 %-Umsätzen (kein eigenständiges ELSTER-Kz) + $kz41 = $n('41') + $st('ig_lieferung'); + $kz43 = $n('43') + $st('export'); + $kz48 = $n('48'); + $kz89 = $st('ig_erwerb_19'); + $kz93 = $st('ig_erwerb_7'); + $kz66 = $u('66'); // § 14, 14a + $kz61 = round($kz89 * 0.19, 2) + round($kz93 * 0.07, 2); // § 3d Satz 1 + $kz62 = 0.0; // Einfuhrumsatzsteuer n.v. + $kz67 = 0.0; // § 13b-Vorsteuer n.v. + // ig. Erwerb-USt gilt gleichzeitig als Output- und als Vorsteuer (kz61) → neutral + $igErwerbUst = round($kz89 * 0.19, 2) + round($kz93 * 0.07, 2); // = kz61 + $kz83 = ($ust19 + $ust7 + $igErwerbUst) - ($kz66 + $kz61 + $kz62 + $kz67); return [ - '81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], - '83' => ['name' => 'USt auf steuerpfl. Umsätze 19 %', 'betrag' => null, 'steuer' => round($kz83, 2)], - '86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => round($kz86, 2), 'steuer' => null], - '84' => ['name' => 'USt auf steuerpfl. Umsätze 7 %', 'betrag' => null, 'steuer' => round($kz84, 2)], - '35' => ['name' => 'Steuerfreie Umsätze (ohne Vorsteuerabzug)', 'betrag' => round($kz35, 2), 'steuer' => null], - '41' => ['name' => 'Steuerfreie ig. Lieferungen (§ 4 Nr. 1b)', 'betrag' => round($kz41, 2), 'steuer' => null], - '44' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz44, 2), 'steuer' => null], - '89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null], - '91' => ['name' => 'USt auf ig. Erwerb 19 %', 'betrag' => null, 'steuer' => round($kz91, 2)], - '93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null], - '94' => ['name' => 'USt auf ig. Erwerb 7 %', 'betrag' => null, 'steuer' => round($kz94, 2)], - '61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => round($kz61, 2)], - '62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => round($kz62, 2)], - '67' => ['name' => 'Vorsteuer aus ig. Erwerb', 'betrag' => null, 'steuer' => round($kz67, 2)], - '66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => round($kz66, 2)], - '69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => round($kz69, 2)], + '81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], + '86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => round($kz86, 2), 'steuer' => null], + '43' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz43, 2), 'steuer' => null], + '48' => ['name' => 'Steuerfreie Umsätze ohne Vorsteuerabzug', 'betrag' => round($kz48, 2), 'steuer' => null], + '41' => ['name' => 'Steuerfreie ig. Lieferungen (§ 4 Nr. 1b)', 'betrag' => round($kz41, 2), 'steuer' => null], + '89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null], + '93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null], + '66' => ['name' => 'Vorsteuer aus Lieferungen/Leistungen (§§ 14, 14a)', 'betrag' => null, 'steuer' => round($kz66, 2)], + '61' => ['name' => 'Vorsteuer aus ig. Erwerben (§ 3d UStG)', 'betrag' => null, 'steuer' => round($kz61, 2)], + '62' => ['name' => 'Vorsteuer aus Einfuhrumsatzsteuer', 'betrag' => null, 'steuer' => round($kz62, 2)], + '67' => ['name' => 'Vorsteuer aus Leistungen nach § 13b UStG', 'betrag' => null, 'steuer' => round($kz67, 2)], + '83' => ['name' => 'Verbleibende Umsatzsteuer-Vorauszahlung / Überschuss', 'betrag' => null, 'steuer' => round($kz83, 2)], + '_ust' => ['name' => 'Umsatzsteuer gesamt (intern)', 'betrag' => null, 'steuer' => round($ust19 + $ust7, 2)], ]; } @@ -186,13 +173,11 @@ class UstvaCalculator */ public function fetchBookingsForKz(int $year, int $month, string $kz): array { - $accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? []; - $steuerartList = self::KZ_STEUERART_MAP[$kz] ?? []; + $accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? []; + $steuerartList = self::KZ_STEUERART_MAP[$kz] ?? []; + $includesVst = in_array($kz, self::KZ_INCLUDES_VST, true); - // Kz 61/62: buchungsbasiert via ust_satz - $vstSatz = array_search($kz, self::VST_SATZ_KZ, true); - - if (empty($accountKzList) && empty($steuerartList) && $vstSatz === false) { + if (empty($accountKzList) && empty($steuerartList) && !$includesVst) { return []; } @@ -226,16 +211,14 @@ class UstvaCalculator ); } - if ($vstSatz !== false) { + if ($includesVst) { $euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?')); $parts[] = $select . ' AND bk.buchungstyp = \'Ausgabe\'' . ' AND bk.betrag_ust != 0' - . ' AND ROUND(bk.ust_satz) = ?' . ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))'; $params = array_merge($params, [$year, $month], - [$vstSatz], self::EU_STEUERARTEN ); } diff --git a/src/Views/tax/ustva_kz_bookings.php b/src/Views/tax/ustva_kz_bookings.php index fa386d5..717adb2 100644 --- a/src/Views/tax/ustva_kz_bookings.php +++ b/src/Views/tax/ustva_kz_bookings.php @@ -3,10 +3,10 @@ use Steuerkram\View; $h = fn($v) => View::h($v); $fmtKz = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; -// Für Kz 69: vorzeichenbehafteter USt-Beitrag zur Zahllast. +// Für Kz 83: vorzeichenbehafteter USt-Beitrag zur Zahllast. // Einnahmen mit USt = positiv, Ausgaben mit Vorsteuer = negativ, // ig_erwerb heben sich gegenseitig auf → 0. -$ustBeitragKz69 = function(array $b): float { +$ustBeitragKz83 = function(array $b): float { $steuerart = $b['steuerart'] ?? 'normal'; $ustSatz = (float)$b['ust_satz']; $betragUst = (float)$b['betrag_ust']; @@ -16,7 +16,7 @@ $ustBeitragKz69 = function(array $b): float { if ($b['buchungstyp'] === 'Ausgabe' && $steuerart === 'normal' && $betragUst != 0) { return -$betragUst; // Vorsteuer → Vorzeichen umkehren } - return 0.0; // steuerfreie Umsätze und ig_erwerb (KZ91-KZ94 = KZ67, Netto-Beitrag 0) + return 0.0; // steuerfreie Umsätze und ig_erwerb (Netto-Beitrag 0, da neutral) }; // Ausgaben werden als negative Beträge angezeigt (Geld fließt ab). @@ -49,7 +49,7 @@ $signedBetrag = fn(array $b, string $field): float => Kategorie Zahlungskonto Netto - + Brutto Anlage @@ -60,7 +60,7 @@ $signedBetrag = fn(array $b, string $field): float => $sumUst = 0.0; $sumBrutto = 0.0; foreach ($bookings as $b): - $ust = $kz === '69' ? $ustBeitragKz69($b) : $signedBetrag($b, 'betrag_ust'); + $ust = $kz === '83' ? $ustBeitragKz83($b) : $signedBetrag($b, 'betrag_ust'); $netto = $signedBetrag($b, 'betrag_netto'); $brutto = $signedBetrag($b, 'betrag_brutto'); $sumNetto += $netto; diff --git a/src/Views/tax/ustva_show.php b/src/Views/tax/ustva_show.php index 5bcdc3f..3ebaad9 100644 --- a/src/Views/tax/ustva_show.php +++ b/src/Views/tax/ustva_show.php @@ -30,11 +30,10 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' ['81', '86', '35', '41', '44'], - 'Umsatzsteuer auf Umsätze' => ['83', '84'], - 'Innergemeinschaftlicher Erwerb' => ['89', '91', '93', '94'], - 'Vorsteuerbeträge' => ['61', '62', '67', '66'], - 'Verbleibende Zahllast' => ['69'], + 'Lieferungen und sonstige Leistungen' => ['81', '86', '43', '48', '41'], + 'Innergemeinschaftlicher Erwerb' => ['89', '93'], + 'Abziehbare Vorsteuerbeträge' => ['66', '61', '62', '67'], + 'Verbleibende Zahllast' => ['83'], ]; foreach ($sections as $sectionTitle => $kzNrs): ?> @@ -46,7 +45,7 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' @@ -109,16 +108,16 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' USt gesamt - + Vorsteuer – - = 0 ? 'Vorauszahlung' : 'Erstattung' ?> - - + = 0 ? 'Vorauszahlung' : 'Erstattung' ?> + + diff --git a/tests/05_TaxSummenTest.php b/tests/05_TaxSummenTest.php index ef35c1c..4d90f6b 100644 --- a/tests/05_TaxSummenTest.php +++ b/tests/05_TaxSummenTest.php @@ -111,30 +111,15 @@ class TaxSummenTest extends TestCase 'UStVA Kz 81 (Umsätze 19%): Betrag enthält Einnahme ≥ 500 €' ); - // Kz 83: USt auf Umsätze 19% – abgeleitet (19% von Kz81-Betrag) + // Kz 66: reguläre Inlandsvorsteuer (§§ 14, 14a) – muss mindestens 38 € enthalten $this->assert( - (float)$kz['83']['steuer'] >= 95.00, - 'UStVA Kz 83 (USt 19%): Steuerbetrag ≥ 95 €' + (float)$kz['66']['steuer'] >= 38.00, + 'UStVA Kz 66 (Vorsteuer §§ 14, 14a): Steuerbetrag enthält Ausgabe ≥ 38 €' ); - // Kz 61: Vorsteuer 19% – muss mindestens 38 € enthalten - $this->assert( - (float)$kz['61']['steuer'] >= 38.00, - 'UStVA Kz 61 (Vorsteuer 19%): Steuerbetrag enthält Ausgabe ≥ 38 €' - ); - - // Kz 66: Vorsteuer gesamt – muss ≥ Kz 61 sein - $this->assert( - (float)$kz['66']['steuer'] >= (float)$kz['61']['steuer'], - 'UStVA Kz 66 (Vorsteuer gesamt): ≥ Kz 61' - ); - - // Kz 69 Berechnung: USt gesamt - Vorsteuer gesamt - $erwKz69 = round( - ((float)$kz['83']['steuer'] + (float)$kz['84']['steuer']) - (float)$kz['66']['steuer'], - 2 - ); - $this->assertEquals($erwKz69, (float)$kz['69']['steuer'], 'UStVA Kz 69 (Zahllast) korrekt berechnet'); + // Kz 83: Zahllast = _ust - Kz66 (USt gesamt minus Vorsteuer) + $erwKz83 = round((float)$kz['_ust']['steuer'] - (float)$kz['66']['steuer'], 2); + $this->assertEquals($erwKz83, (float)$kz['83']['steuer'], 'UStVA Kz 83 (Zahllast) korrekt berechnet'); // ── EÜR prüfen ──────────────────────────────────────────────────── $euerCalc = new \Steuerkram\Services\EuerCalculator(); diff --git a/tests/07_UstvaAuslandTest.php b/tests/07_UstvaAuslandTest.php index 92e6ef3..6eb872c 100644 --- a/tests/07_UstvaAuslandTest.php +++ b/tests/07_UstvaAuslandTest.php @@ -6,7 +6,7 @@ declare(strict_types=1); * Test 7: UStVA-Kennzahlen für Auslandsumsätze und ig. Erwerb. * * Prüft, dass steuerart-Buchungen korrekt in die UStVA-Kennzahlen - * 41, 44, 89, 91, 93, 94 und 67 einfließen. + * 41, 43, 89, 93 und 61 einfließen. */ class UstvaAuslandTest extends TestCase { @@ -36,6 +36,11 @@ class UstvaAuslandTest extends TestCase ); $bankId = $bankId ? (int)$bankId['id'] : $zahlungId; + // Baseline Kz83 VOR den Test-Buchungen (reale Buchungen im laufenden Monat) + $calcPre = new \Steuerkram\Services\UstvaCalculator(); + $kzPre = $calcPre->calculate($year, $month); + $kz83Pre = (float)$kzPre['83']['steuer']; + // ── 1. Steuerfreie ig. Lieferung → Kz 41 ────────────────────────── $belegIgL = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGL-' . uniqid(), @@ -58,7 +63,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test ig. Lieferung EU', ]); - // ── 2. Steuerfreie Ausfuhr → Kz 44 ─────────────────────────────── + // ── 2. Steuerfreie Ausfuhr → Kz 43 ─────────────────────────────── $belegExp = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-EXP-' . uniqid(), 'filename' => 'test-exp.pdf', @@ -80,7 +85,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test Ausfuhr Drittland', ]); - // ── 3. Ig. Erwerb 19 % → Kz 89/91/67 ───────────────────────────── + // ── 3. Ig. Erwerb 19 % → Kz 89 (Bemessungsgrundlage), Kz 61 (Vorsteuer) ── $belegIgE19 = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGE19-' . uniqid(), 'filename' => 'test-ige19.pdf', @@ -102,7 +107,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test ig. Erwerb 19%', ]); - // ── 4. Ig. Erwerb 7 % → Kz 93/94/67 ────────────────────────────── + // ── 4. Ig. Erwerb 7 % → Kz 93 (Bemessungsgrundlage), Kz 61 (Vorsteuer) ─── $belegIgE7 = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGE7-' . uniqid(), 'filename' => 'test-ige7.pdf', @@ -134,10 +139,10 @@ class UstvaAuslandTest extends TestCase 'Kz 41 (ig. Lieferung): Betrag ≥ 1.000 €' ); - // Kz 44: Ausfuhr 2000 € + // Kz 43: Ausfuhr 2000 € $this->assert( - (float)$kz['44']['betrag'] >= 2000.00, - 'Kz 44 (Ausfuhr): Betrag ≥ 2.000 €' + (float)$kz['43']['betrag'] >= 2000.00, + 'Kz 43 (Ausfuhr): Betrag ≥ 2.000 €' ); // Kz 89: ig. Erwerb 19% Bemessungsgrundlage 500 € @@ -146,52 +151,33 @@ class UstvaAuslandTest extends TestCase 'Kz 89 (ig. Erwerb 19% Bmgl.): Betrag ≥ 500 €' ); - // Kz 91: USt auf ig. Erwerb 19% = 500 * 0.19 = 95 € - $this->assertEquals( - round(500.00 * 0.19, 2), - (float)$kz['91']['steuer'], - 'Kz 91 (USt ig. Erwerb 19%): Steuer = 95,00 €' - ); - // Kz 93: ig. Erwerb 7% Bemessungsgrundlage 300 € $this->assert( (float)$kz['93']['betrag'] >= 300.00, 'Kz 93 (ig. Erwerb 7% Bmgl.): Betrag ≥ 300 €' ); - // Kz 94: USt auf ig. Erwerb 7% = 300 * 0.07 = 21 € + // Kz 61: Vorsteuer aus ig. Erwerben (§ 3d) = 500*0.19 + 300*0.07 = 95 + 21 = 116 € + $erwartetKz61 = round(500.00 * 0.19, 2) + round(300.00 * 0.07, 2); $this->assertEquals( - round(300.00 * 0.07, 2), - (float)$kz['94']['steuer'], - 'Kz 94 (USt ig. Erwerb 7%): Steuer = 21,00 €' + $erwartetKz61, + (float)$kz['61']['steuer'], + 'Kz 61 (Vorsteuer ig. Erwerb § 3d): Steuer = 116,00 €' ); - // Kz 67: Vorsteuer aus ig. Erwerb = Kz 91 + Kz 94 = 116 € - $erwartetKz67 = round((float)$kz['91']['steuer'] + (float)$kz['94']['steuer'], 2); + // Kz 83 (Zahllast): ig. Erwerb/Lieferung/Ausfuhr sind USt-neutral (betrag_ust=0) + // → Zahllast darf sich gegenüber Baseline nicht verändert haben $this->assertEquals( - $erwartetKz67, - (float)$kz['67']['steuer'], - 'Kz 67 (Vorsteuer ig. Erwerb) = Kz 91 + Kz 94' + round($kz83Pre, 2), + (float)$kz['83']['steuer'], + 'Kz 83 (Zahllast) unverändert – alle EU-Buchungen sind USt-neutral' ); - // Kz 69 Plausibilitätsprüfung: ig. Erwerb neutralisiert sich durch Kz 67 - // USt auf Umsätze (83+84) + ig. Erwerb (91+94) - Vorsteuer (66) = 69 - $ust = (float)$kz['83']['steuer'] + (float)$kz['84']['steuer'] - + (float)$kz['91']['steuer'] + (float)$kz['94']['steuer']; - $erwartetKz69 = round($ust - (float)$kz['66']['steuer'], 2); - $this->assertEquals( - $erwartetKz69, - (float)$kz['69']['steuer'], - 'Kz 69 (Zahllast) korrekt berechnet' - ); - - // ig. Erwerb darf NICHT in Kz 35 oder Kz 61/62 landen - // (kz35 enthält nur normale 0%-Einnahmen, kz61 nur normale Vorsteuer) - // Wir buchten keine normalen 0%-Einnahmen, also muss Kz 35 = 0 sein + // ig. Erwerb darf NICHT in Kz 48 landen (steuerfreie Umsätze ohne VSt-Abzug) $this->assertEquals( 0.0, - (float)$kz['35']['betrag'], - 'Kz 35 (steuerfreie Inlandsumsätze) = 0 (kein normaler 0%-Umsatz gebucht)' + (float)$kz['48']['betrag'], + 'Kz 48 (steuerfreie Inlandsumsätze) = 0 (kein steuerfreier Inlandsumsatz gebucht)' ); } diff --git a/tests/13_ElsterXmlKz39Test.php b/tests/13_ElsterXmlKz39Test.php index 1c4cfc5..76c61a3 100644 --- a/tests/13_ElsterXmlKz39Test.php +++ b/tests/13_ElsterXmlKz39Test.php @@ -6,29 +6,33 @@ use Steuerkram\Services\ElsterXmlService; class ElsterXmlKz39Test extends TestCase { - protected string $testName = 'Test 13: UStVA XML – Kz39 Vorzeichen-Validierung'; + protected string $testName = 'Test 13: UStVA XML – Kz83 Vorauszahlung/Überschuss'; public function run(): void { $service = new ElsterXmlService(); - // Fall 1: Zahllast positiv → Kz39 muss vorhanden und positiv sein - $kzZahllast = ['69' => ['steuer' => 150.00]]; + // Fall 1: Zahllast positiv → Kz83 muss vorhanden und positiv sein + $kzZahllast = ['83' => ['steuer' => 150.00]]; $xml = $service->generate(2026, 1, $kzZahllast); - $this->assert(str_contains($xml, ''), 'Kz39 bei Zahllast vorhanden'); - $this->assert(str_contains($xml, '150.00'), 'Kz39 ohne Vorzeichen bei Zahllast'); + $this->assert(str_contains($xml, ''), 'Kz83 bei Zahllast vorhanden'); + $this->assert(str_contains($xml, '150.00'), 'Kz83 enthält korrekten Betrag bei Zahllast'); - // Fall 2: Erstattung (negativ) → Kz39 darf nicht vorkommen - $kzErstattung = ['69' => ['steuer' => -50.00]]; + // Fall 2: Erstattung (negativ) → Kz83 muss vorhanden sein (Überschuss) + $kzErstattung = ['83' => ['steuer' => -50.00]]; $xml = $service->generate(2026, 1, $kzErstattung); - $this->assert(!str_contains($xml, ''), 'Kz39 bei Erstattung nicht vorhanden'); + $this->assert(str_contains($xml, ''), 'Kz83 bei Erstattung vorhanden'); + $this->assert(str_contains($xml, '-50.00'), 'Kz83 enthält negativen Betrag bei Erstattung'); - // Fall 3: Zahllast = 0 → Kz39 weglassen - $kzNull = ['69' => ['steuer' => 0.0]]; + // Fall 3: Zahllast = 0 → Kz83 weglassen + $kzNull = ['83' => ['steuer' => 0.0]]; $xml = $service->generate(2026, 1, $kzNull); - $this->assert(!str_contains($xml, ''), 'Kz39 bei Nullbetrag nicht vorhanden'); + $this->assert(!str_contains($xml, ''), 'Kz83 bei Nullbetrag nicht vorhanden'); - // Fall 4: XML ist valides XML + // Fall 4: Kz39 (Sondervorauszahlung) darf NICHT mehr als Zahllast exportiert werden + $this->assert(!str_contains($xml, ''), 'Kz39 (Sondervorauszahlung) wird nicht exportiert'); + + // Fall 5: XML ist valides XML $doc = new \DOMDocument(); $loaded = @$doc->loadXML($xml); $this->assert($loaded !== false, 'Generiertes XML ist wohlgeformt'); diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index ec9294e..366546d 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -93,12 +93,12 @@ class StornoBuchungTest extends TestCase ); // ── UStVA: Vorsteuer-KZ hebt sich auf ───────────────────────── - // Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61'). + // Kz 66 enthält alle inländischen Ausgaben mit Vorsteuer (buchungsbasiert). // Original: +19, Storno: -19 → Summe = 0 (oder weniger als andere Tests einbringen). // Da weitere Tests parallel laufen können, prüfen wir nur, dass die Storno-Buchung // als eigenständige Zeile in fetchBookingsForKz erscheint. $ustvaCalc = new \Steuerkram\Services\UstvaCalculator(); - $bookings = $ustvaCalc->fetchBookingsForKz($year, $month, '61'); + $bookings = $ustvaCalc->fetchBookingsForKz($year, $month, '66'); $reversalFound = false; foreach ($bookings as $b) { @@ -109,6 +109,6 @@ class StornoBuchungTest extends TestCase break; } } - $this->assert($reversalFound, 'UStVA-Drilldown Kz 61: Stornobuchung erscheint in Detailansicht'); + $this->assert($reversalFound, 'UStVA-Drilldown Kz 66: Stornobuchung erscheint in Detailansicht'); } } From 41d64dc6f64b5f470b3b95bf85c728e1e0eb81cd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 18:07:41 +0200 Subject: [PATCH 12/14] =?UTF-8?q?Fix=20#57:=20Lineare=20Abschreibungstabel?= =?UTF-8?q?le=20f=C3=BCr=20Anlageg=C3=BCter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 16 + docs/anlagevermögen.md | 33 +- migrations/035_assets_abschreibung_flags.sql | 8 + .../036_asset_depreciation_schedule.sql | 13 + public/index.php | 14 +- src/Controllers/AssetController.php | 151 ++++++-- src/Models/Asset.php | 33 +- src/Models/AssetDepreciation.php | 116 ++++++ src/Views/assets/form.php | 361 +++++++++++++++++- tests/26_AbschreibungstabelleTest.php | 121 ++++++ 10 files changed, 804 insertions(+), 62 deletions(-) create mode 100644 migrations/035_assets_abschreibung_flags.sql create mode 100644 migrations/036_asset_depreciation_schedule.sql create mode 100644 src/Models/AssetDepreciation.php create mode 100644 tests/26_AbschreibungstabelleTest.php diff --git a/Changes.md b/Changes.md index 5c5092c..3550cd2 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,22 @@ --- +## 2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter + +### Hinzugefügt +- `migrations/035_assets_abschreibung_flags.sql`: Neue Spalten `automatisch_berechnet` und `cent_im_ersten_jahr` in `assets` +- `migrations/036_asset_depreciation_schedule.sql`: Neue Tabelle `asset_depreciation_schedule` (Jahr, Abschreibungsbetrag, Restwert) +- `src/Models/AssetDepreciation.php`: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenz +- `tests/26_AbschreibungstabelleTest.php`: 22 Tests für alle AfA-Szenarien + +### Geändert +- `src/Models/Asset.php`: `create()` und `update()` speichern `automatisch_berechnet` und `cent_im_ersten_jahr` +- `src/Controllers/AssetController.php`: Abschreibungstabelle wird nach Speichern automatisch generiert; neue Endpunkte `/assets/depreciation-preview` (AJAX-Vorschau) und `/assets/{id}/depreciation` (manuelle Speicherung) +- `src/Views/assets/form.php`: Nutzungsdauer-Dropdown (sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre + individuell), Cent-Option, „AfA-Tabelle"-Button, Modal mit Auto/Manuell-Toggle und editierbarer Tabelle +- `public/index.php`: Neue Routen für Depreciation-Preview und -Speicherung + +--- + ## 2026-04-26 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert ### Geändert diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md index e196242..6ab83ba 100644 --- a/docs/anlagevermögen.md +++ b/docs/anlagevermögen.md @@ -65,5 +65,36 @@ Folgende Felder können nachträglich angepasst werden: - Anschaffungsdatum und -kosten - Zugänge und Abgänge - Abschreibung laufendes Jahr -- Nutzungsdauer (in Jahren) +- Nutzungsdauer (Dropdown: sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre oder individuell) +- Centbeträge im ersten Jahr abschreiben (Option) - Notizen + +--- + +## Abschreibungstabelle (AfA) + +Für jedes gespeicherte Wirtschaftsgut wird automatisch eine lineare AfA-Tabelle erzeugt und persistent gespeichert (`asset_depreciation_schedule`). Die Tabelle enthält pro Jahr: **Jahr**, **Abschreibungsbetrag**, **Restwert**. + +### Berechnungsregeln + +| Modus | Verhalten | +|---|---| +| **Nutzungsdauer = sofort** | Vollständige Abschreibung im Anschaffungsjahr, Restwert = 0 | +| **Nutzungsdauer = N Jahre** | Gleichmäßige lineare Verteilung; letztes Jahr gleicht Rundungsdifferenzen aus | +| **Cent-Option aktiv** | Folgejahre auf volle Euro abgerundet; erstes Jahr übernimmt alle Centbeträge; Restwert nach Jahr 1 ist volle Euro | + +Basis der Berechnung: `Anschaffungskosten + Zugänge − Abgänge`. + +### Auto- und Manuell-Modus + +Die Tabelle besitzt ein Flag `automatisch_berechnet`: +- **Automatisch (Standard):** Bei jeder Parameteränderung (Nutzungsdauer, Cent-Option, Kosten) wird die Tabelle neu berechnet. +- **Manuell:** Durch Klick auf „Manuell bearbeiten" im Modal wird das Flag deaktiviert. Parameteränderungen zeigen eine Bestätigungsabfrage, bevor neu berechnet wird. + +### Bedienung + +1. Wirtschaftsgut speichern → Tabelle wird automatisch berechnet. +2. Button **„AfA-Tabelle"** öffnet den modalen Dialog. +3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen. +4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung). +5. **„Speichern"** sichert die aktuelle Tabelle (inklusive manueller Änderungen) in der Datenbank. diff --git a/migrations/035_assets_abschreibung_flags.sql b/migrations/035_assets_abschreibung_flags.sql new file mode 100644 index 0000000..e8da606 --- /dev/null +++ b/migrations/035_assets_abschreibung_flags.sql @@ -0,0 +1,8 @@ +-- Migration 035: Abschreibungsflags für Wirtschaftsgüter +ALTER TABLE assets + ADD COLUMN IF NOT EXISTS automatisch_berechnet TINYINT(1) NOT NULL DEFAULT 1 + COMMENT 'true = Abschreibungstabelle automatisch berechnet, false = manuell bearbeitet' + AFTER nutzungsdauer, + ADD COLUMN IF NOT EXISTS cent_im_ersten_jahr TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Centbeträge vollständig im ersten Jahr abschreiben; Folgejahre auf volle Euro' + AFTER automatisch_berechnet; diff --git a/migrations/036_asset_depreciation_schedule.sql b/migrations/036_asset_depreciation_schedule.sql new file mode 100644 index 0000000..d0d07c5 --- /dev/null +++ b/migrations/036_asset_depreciation_schedule.sql @@ -0,0 +1,13 @@ +-- Migration 036: Abschreibungstabelle je Wirtschaftsgut +CREATE TABLE IF NOT EXISTS asset_depreciation_schedule ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + asset_id INT UNSIGNED NOT NULL, + jahr SMALLINT UNSIGNED NOT NULL COMMENT 'Kalenderjahr der Abschreibung', + abschreibungsbetrag DECIMAL(12,2) NOT NULL DEFAULT 0.00, + restwert DECIMAL(12,2) NOT NULL DEFAULT 0.00, + position SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Reihenfolge (0-basiert)', + PRIMARY KEY (id), + KEY idx_ads_asset_id (asset_id), + UNIQUE KEY uq_ads_asset_jahr (asset_id, jahr) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Lineare Abschreibungstabelle je Wirtschaftsgut'; diff --git a/public/index.php b/public/index.php index fef6b00..f8f0dfe 100644 --- a/public/index.php +++ b/public/index.php @@ -100,12 +100,14 @@ $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLi $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); // ── Anlagevermögen ─────────────────────────────────────────── -$router->get('/assets', fn($p) => (new AssetController())->index($p)); -$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); -$router->post('/assets', fn($p) => (new AssetController())->store($p)); -$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p)); -$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p)); -$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p)); +$router->get('/assets', fn($p) => (new AssetController())->index($p)); +$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); +$router->post('/assets', fn($p) => (new AssetController())->store($p)); +$router->get('/assets/depreciation-preview', fn($p) => (new AssetController())->depreciationPreview($p)); +$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p)); +$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p)); +$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p)); +$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p)); // ── UStVA ──────────────────────────────────────────────────── $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index dded27f..a743974 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Asset; +use Steuerkram\Models\AssetDepreciation; use Steuerkram\View; class AssetController @@ -29,10 +30,11 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => [], - 'errors' => [], - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => [], + 'errors' => [], + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); } @@ -47,17 +49,23 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => $data, - 'errors' => $errors, - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => $data, + 'errors' => $errors, + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); return; } - Asset::create($data); - View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.'); + $id = Asset::create($data); + $asset = Asset::find($id); + if ($asset && (int)($asset['automatisch_berechnet'] ?? 1) === 1) { + AssetDepreciation::generateAndSave($asset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut erfolgreich angelegt.'); } public function edit(array $params): void @@ -65,14 +73,17 @@ class AssetController $asset = Asset::find((int)$params['id']); if (!$asset) { http_response_code(404); return; } + $scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']); + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => $asset, - 'errors' => [], - 'action' => '/assets/' . $asset['id'], - 'anlagearten' => Asset::anlagearten(), + 'asset' => $asset, + 'errors' => [], + 'action' => '/assets/' . $asset['id'], + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); } @@ -87,46 +98,128 @@ class AssetController $errors = $this->validate($data); if ($errors) { + $scheduleRows = AssetDepreciation::findByAsset($id); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => array_merge($asset, $data), - 'errors' => $errors, - 'action' => '/assets/' . $id, - 'anlagearten' => Asset::anlagearten(), + 'asset' => array_merge($asset, $data), + 'errors' => $errors, + 'action' => '/assets/' . $id, + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); return; } + // Wenn Parameter geändert und Tabelle war manuell: Flag beibehalten + $wasAutomatisch = (bool)(int)($asset['automatisch_berechnet'] ?? 1); Asset::update($id, $data); - View::redirect('/assets', 'Wirtschaftsgut aktualisiert.'); + + $updatedAsset = Asset::find($id); + $isAutomatisch = (bool)(int)($updatedAsset['automatisch_berechnet'] ?? 1); + + if ($isAutomatisch) { + AssetDepreciation::generateAndSave($updatedAsset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut aktualisiert.'); } public function destroy(array $params): void { - if (!Asset::delete((int)$params['id'])) { + $id = (int)$params['id']; + AssetDepreciation::deleteByAsset($id); + if (!Asset::delete($id)) { View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error'); return; } View::redirect('/assets', 'Wirtschaftsgut gelöscht.'); } + /** + * AJAX: Abschreibungstabelle vorschau berechnen (ohne DB-Speicherung) + * GET /assets/depreciation-preview?anschaffungskosten=...&nutzungsdauer=...&... + */ + public function depreciationPreview(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $asset = [ + 'anschaffungskosten' => (float)($_GET['anschaffungskosten'] ?? 0), + 'zugaenge' => (float)($_GET['zugaenge'] ?? 0), + 'abgaenge' => (float)($_GET['abgaenge'] ?? 0), + 'nutzungsdauer' => (int)($_GET['nutzungsdauer'] ?? 0), + 'cent_im_ersten_jahr'=> !empty($_GET['cent_im_ersten_jahr']) ? 1 : 0, + 'anschaffungsdatum' => $_GET['anschaffungsdatum'] ?? date('Y-01-01'), + ]; + + $rows = AssetDepreciation::calculate($asset); + echo json_encode(['rows' => $rows]); + } + + /** + * AJAX: Manuell bearbeitete Abschreibungstabelle speichern + * POST /assets/{id}/depreciation + */ + public function saveDepreciation(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $id = (int)$params['id']; + $asset = Asset::find($id); + if (!$asset) { + http_response_code(404); + echo json_encode(['error' => 'Asset nicht gefunden']); + return; + } + + $body = json_decode(file_get_contents('php://input'), true); + if (!is_array($body) || !isset($body['rows'])) { + http_response_code(400); + echo json_encode(['error' => 'Ungültige Daten']); + return; + } + + $rows = []; + foreach ($body['rows'] as $i => $row) { + $rows[] = [ + 'jahr' => (int)($row['jahr'] ?? 0), + 'abschreibungsbetrag' => (float)str_replace(',', '.', $row['abschreibungsbetrag'] ?? 0), + 'restwert' => (float)str_replace(',', '.', $row['restwert'] ?? 0), + 'position' => $i, + ]; + } + + AssetDepreciation::saveForAsset($id, $rows); + + // Flag setzen: wenn Rows manuell geändert + $automatisch = !empty($body['automatisch']) ? 1 : 0; + \Steuerkram\Database::execute( + 'UPDATE assets SET automatisch_berechnet = ? WHERE id = ?', + [$automatisch, $id] + ); + + echo json_encode(['ok' => true]); + } + // ── Private helpers ────────────────────────────────────────── private function extractPost(): array { return [ - 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), - 'account_id' => trim($_POST['account_id'] ?? ''), - 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), - 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', - 'zugaenge' => $_POST['zugaenge'] ?? '0', - 'abgaenge' => $_POST['abgaenge'] ?? '0', - 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', - 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), - 'notizen' => trim($_POST['notizen'] ?? ''), + 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), + 'account_id' => trim($_POST['account_id'] ?? ''), + 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), + 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', + 'zugaenge' => $_POST['zugaenge'] ?? '0', + 'abgaenge' => $_POST['abgaenge'] ?? '0', + 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', + 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), + 'automatisch_berechnet'=> isset($_POST['automatisch_berechnet']) ? (int)(bool)$_POST['automatisch_berechnet'] : 1, + 'cent_im_ersten_jahr' => isset($_POST['cent_im_ersten_jahr']) ? 1 : 0, + 'notizen' => trim($_POST['notizen'] ?? ''), ]; } diff --git a/src/Models/Asset.php b/src/Models/Asset.php index 6307cef..7d65e8f 100644 --- a/src/Models/Asset.php +++ b/src/Models/Asset.php @@ -85,8 +85,9 @@ class Asset Database::execute( 'INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten, - zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, automatisch_berechnet, + cent_im_ersten_jahr, notizen, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $data['lfd_nr'] ?? self::nextLfdNr(), $anlageNr, @@ -98,6 +99,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, ] @@ -116,17 +119,19 @@ class Asset Database::execute( 'UPDATE assets SET - bezeichnung = ?, - account_id = ?, - anschaffungsdatum = ?, - anschaffungskosten = ?, - zugaenge = ?, - abgaenge = ?, - abschreibung_jahr = ?, - nutzungsdauer = ?, - notizen = ?, - fiscal_year_id = COALESCE(?, fiscal_year_id), - updated_at = NOW() + bezeichnung = ?, + account_id = ?, + anschaffungsdatum = ?, + anschaffungskosten = ?, + zugaenge = ?, + abgaenge = ?, + abschreibung_jahr = ?, + nutzungsdauer = ?, + automatisch_berechnet = ?, + cent_im_ersten_jahr = ?, + notizen = ?, + fiscal_year_id = COALESCE(?, fiscal_year_id), + updated_at = NOW() WHERE id = ?', [ $data['bezeichnung'], @@ -137,6 +142,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, $id, diff --git a/src/Models/AssetDepreciation.php b/src/Models/AssetDepreciation.php new file mode 100644 index 0000000..a96da53 --- /dev/null +++ b/src/Models/AssetDepreciation.php @@ -0,0 +1,116 @@ + $startjahr, + 'abschreibungsbetrag' => round($basis, 2), + 'restwert' => 0.00, + 'position' => 0, + ]; + return $rows; + } + + if ($centImErstenJahr) { + // Folgejahre: auf volle Euro gerundet (abgerundet) + $regelJahr = floor($basis / $nutzungsdauer); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } else { + $regelJahr = round($basis / $nutzungsdauer, 2); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } + + $restwert = $basis; + for ($i = 0; $i < $nutzungsdauer; $i++) { + $afa = ($i === 0) ? $erstesJahr : (float)$regelJahr; + $restwert = round($restwert - $afa, 2); + // Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler) + if ($i === $nutzungsdauer - 1) { + $afa = round($afa + $restwert, 2); + $restwert = 0.00; + } + $rows[] = [ + 'jahr' => $startjahr + $i, + 'abschreibungsbetrag' => $afa, + 'restwert' => $restwert, + 'position' => $i, + ]; + } + + return $rows; + } + + public static function saveForAsset(int $assetId, array $rows): void + { + self::deleteByAsset($assetId); + foreach ($rows as $row) { + Database::execute( + 'INSERT INTO asset_depreciation_schedule + (asset_id, jahr, abschreibungsbetrag, restwert, position) + VALUES (?, ?, ?, ?, ?)', + [ + $assetId, + (int)$row['jahr'], + round((float)$row['abschreibungsbetrag'], 2), + round((float)$row['restwert'], 2), + (int)$row['position'], + ] + ); + } + } + + public static function deleteByAsset(int $assetId): void + { + Database::execute( + 'DELETE FROM asset_depreciation_schedule WHERE asset_id = ?', + [$assetId] + ); + } + + public static function generateAndSave(array $asset): void + { + $id = (int)($asset['id'] ?? 0); + if ($id <= 0) { + return; + } + $rows = self::calculate($asset); + self::saveForAsset($id, $rows); + } +} diff --git a/src/Views/assets/form.php b/src/Views/assets/form.php index e5b5aea..9a115d0 100644 --- a/src/Views/assets/form.php +++ b/src/Views/assets/form.php @@ -1,6 +1,17 @@ - View::h($v); ?> + View::h($v); -
+$assetId = (int)($asset['id'] ?? 0); +$isEdit = $assetId > 0; +$nutzung = isset($asset['nutzungsdauer']) && $asset['nutzungsdauer'] !== '' ? (int)$asset['nutzungsdauer'] : null; +$predefined = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 15, 20, 25, 50]; +$isCustom = $nutzung !== null && !in_array($nutzung, $predefined, true); +$centFlag = !empty($asset['cent_im_ersten_jahr']); +$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true; +?> + +
@@ -65,18 +76,55 @@
-
-
- - +
+ + +
+ + +
+
+ +
+ + + > +
-
- - +
+ + + +
+
+ +
+ +
+ Wenn aktiv: Restwert nach Jahr 1 auf volle Euro gerundet; Folgejahre gleichmäßig ohne Centabweichungen.
@@ -85,6 +133,10 @@
+ + +
Abbrechen @@ -92,3 +144,286 @@
+ + + + + + + diff --git a/tests/26_AbschreibungstabelleTest.php b/tests/26_AbschreibungstabelleTest.php new file mode 100644 index 0000000..b6453ff --- /dev/null +++ b/tests/26_AbschreibungstabelleTest.php @@ -0,0 +1,121 @@ + 1000.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 0, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2025-03-15', + ]); + + $this->assertEquals(1, count($rows), 'Sofortabschreibung: genau 1 Zeile'); + $this->assertEquals(2025, $rows[0]['jahr'], 'Sofortabschreibung: Startjahr korrekt'); + $this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Sofortabschreibung: voller Betrag'); + $this->assertEquals(0.00, $rows[0]['restwert'], 'Sofortabschreibung: Restwert 0'); + + // ── Gleichmäßige lineare AfA ohne Cent-Option ──────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 3000.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2023-01-01', + ]); + + $this->assertEquals(3, count($rows), 'Lineare AfA 3 Jahre: 3 Zeilen'); + $this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Jahr 1: 1000,00'); + $this->assertEquals(2000.00, $rows[0]['restwert'], 'Jahr 1 Restwert: 2000,00'); + $this->assertEquals(1000.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3: 1000,00'); + $this->assertEquals(0.00, $rows[2]['restwert'], 'Jahr 3 Restwert: 0,00'); + + // ── Cent-Option: Centbeträge im ersten Jahr ────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1000.75, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> true, + 'anschaffungsdatum' => '2024-01-01', + ]); + + $this->assertEquals(3, count($rows), 'Cent-Option 3 Jahre: 3 Zeilen'); + // Folgejahre: floor(1000.75/3) = 333 € + $this->assertEquals(333.00, $rows[1]['abschreibungsbetrag'], 'Jahr 2 mit Cent-Option: 333,00'); + $this->assertEquals(333.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3 mit Cent-Option: 333,00'); + // Erstes Jahr: 1000.75 - 333*2 = 334.75 + $this->assertEquals(334.75, $rows[0]['abschreibungsbetrag'], 'Jahr 1 mit Cent-Option: 334,75'); + // Restwert nach Jahr 1 auf volle Euro + $restwertJ1 = $rows[0]['restwert']; + $this->assert( + $restwertJ1 == floor($restwertJ1), + 'Restwert nach Jahr 1 ist volle Euro: ' . $restwertJ1 + ); + // Gesamtsumme muss Basis entsprechen + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1000.75, round($sumAfa, 2), 'Summe aller Abschreibungen = Basis'); + + // ── Basis mit Zugängen und Abgängen ───────────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 5000.00, + 'zugaenge' => 500.00, + 'abgaenge' => 200.00, + 'nutzungsdauer' => 5, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2022-06-01', + ]); + + $this->assertEquals(5, count($rows), 'AfA mit Zu-/Abgängen: 5 Zeilen'); + $basis = 5000.00 + 500.00 - 200.00; + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals($basis, round($sumAfa, 2), 'Summe AfA = Basis mit Zu-/Abgängen'); + + // ── Leere Tabelle bei Basis <= 0 ───────────────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 0, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 5, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2024-01-01', + ]); + $this->assertEquals(0, count($rows), 'Keine Zeilen bei Basis = 0'); + + // ── Persistenz: Speichern und Laden ────────────────────── + // Erst ein Test-Asset anlegen (Rollback durch TestCase) + \Steuerkram\Database::execute( + "INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, anschaffungsdatum, anschaffungskosten) + VALUES (9999, 'TEST-DEPR', 'Test Abschreibung', '2024-01-01', 2400.00)" + ); + $assetId = (int)\Steuerkram\Database::lastInsertId(); + + $testRows = [ + ['jahr' => 2024, 'abschreibungsbetrag' => 800.00, 'restwert' => 1600.00, 'position' => 0], + ['jahr' => 2025, 'abschreibungsbetrag' => 800.00, 'restwert' => 800.00, 'position' => 1], + ['jahr' => 2026, 'abschreibungsbetrag' => 800.00, 'restwert' => 0.00, 'position' => 2], + ]; + + AssetDepreciation::saveForAsset($assetId, $testRows); + $loaded = AssetDepreciation::findByAsset($assetId); + + $this->assertEquals(3, count($loaded), 'Persistenz: 3 Zeilen gespeichert und geladen'); + $this->assertEquals(2024, (int)$loaded[0]['jahr'], 'Persistenz: Jahr 2024 korrekt'); + $this->assertEquals(800.00, (float)$loaded[0]['abschreibungsbetrag'], 'Persistenz: Betrag korrekt'); + + AssetDepreciation::deleteByAsset($assetId); + $loaded = AssetDepreciation::findByAsset($assetId); + $this->assertEquals(0, count($loaded), 'Persistenz: Nach deleteByAsset keine Zeilen mehr'); + } +} From c6f7f4c0c2e9c730c5884e7bb7845259310a1689 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 21:08:14 +0200 Subject: [PATCH 13/14] Revert "Fix: UStVA-Kennziffern auf offizielle ELSTER-Definitionen korrigiert" This reverts commit bbfb41232e0c4c4f797aaf5a0d6247d76df9ac7e. --- Changes.md | 11 -- docs/berechnungslogik.md | 41 ++++--- src/Controllers/UstvaController.php | 2 +- src/Services/ElsterXmlService.php | 16 +-- src/Services/UstvaCalculator.php | 163 +++++++++++++++------------- src/Views/tax/ustva_kz_bookings.php | 10 +- src/Views/tax/ustva_show.php | 19 ++-- tests/05_TaxSummenTest.php | 27 ++++- tests/07_UstvaAuslandTest.php | 64 ++++++----- tests/13_ElsterXmlKz39Test.php | 28 ++--- tests/18_StornoBuchungTest.php | 6 +- 11 files changed, 216 insertions(+), 171 deletions(-) diff --git a/Changes.md b/Changes.md index 3550cd2..e45ecef 100644 --- a/Changes.md +++ b/Changes.md @@ -18,17 +18,6 @@ --- -## 2026-04-26 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert - -### Geändert -- `src/Services/UstvaCalculator.php`: Kz 66 ist jetzt die primäre Inlandsvorsteuer (§§ 14, 14a) – alle Ausgabe-Buchungen mit Vorsteuer fließen direkt in Kz 66 (buchungsbasiert, unabhängig vom USt-Satz). Kz 61 ist jetzt Vorsteuer aus ig. Erwerben (§ 3d) = Kz89×0,19 + Kz93×0,07. Kz 62 (Einfuhrumsatzsteuer) und Kz 67 (§ 13b) werden auf 0 gesetzt. Kz 83 ist jetzt das Ergebnis (Verbleibende Vorauszahlung / Überschuss) statt der USt auf 19 %-Umsätze. Kz 35/44 (veraltet seit Migration 017) und Kz 91/94 (wurden fälschlich als USt-Beträge auf ig. Erwerb genutzt) entfernt. Kz 43 (Ausfuhren) und Kz 48 (steuerfreie Umsätze ohne VSt-Abzug) hinzugefügt. -- `src/Services/ElsterXmlService.php`: Betragsfelder auf korrekte Kz aktualisiert (43/48 statt 35/44); Kz39-Export (Sondervorauszahlung) durch Kz83-Export (Vorauszahlung/Überschuss) ersetzt. -- `src/Views/tax/ustva_show.php`: Abschnitte und Beschriftungen auf korrekte ELSTER-Kennziffern angepasst. -- `src/Views/tax/ustva_kz_bookings.php`: Referenz auf Kz 69 → Kz 83 umgestellt. -- `src/Controllers/UstvaController.php`: Whitelist gültiger Kennziffern aktualisiert. -- `docs/berechnungslogik.md`: UStVA-Abschnitt vollständig neu beschrieben. - ---- ## 2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index d6b4bb5..9d4cd07 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -43,31 +43,44 @@ Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` d Die UStVA kombiniert drei Quellen: -**Quelle 1 – kontobasiert** (`accounts.ustva_kz`): Umsatz- und steuerfreie Konten. Buchungen mit EU-Steuerarten werden hier ausgeschlossen. +**Quelle 1 – kontobasiert** (`accounts.ustva_kz`): reguläre Umsätze. Buchungen mit EU-Steuerarten werden hier ausgeschlossen. Kz 61/62 sind aus dieser Quelle entfernt – `ustva_kz='61'/'62'` wird ignoriert. -**Quelle 2 – buchungsbasiert** (`buchungstyp='Ausgabe'`, `betrag_ust != 0`): Alle inländischen Eingangsrechnungen mit Vorsteuer → **Kz 66** (§§ 14, 14a UStG). Buchungen mit EU-Steuerarten sind ausgenommen. Der Wert `ustva_kz` auf Vorsteuer-Konten wird nicht ausgewertet. +**Quelle 2 – buchungsbasiert** (`bookings.ust_satz`): Vorsteuer Kz 61/62. Alle Ausgabe-Buchungen mit `betrag_ust != 0` fließen anhand ihres `ust_satz` direkt in Kz 61 (19 %) bzw. Kz 62 (7 %) ein, unabhängig davon welches Konto gebucht wurde. Buchungen mit EU-Steuerarten sind ausgenommen. **Quelle 3 – steuerartbasiert** (`bookings.steuerart`): EU-Sonderfälle (ig. Lieferung, Ausfuhr, ig. Erwerb). Diese Buchungen landen unabhängig vom Kategorie-Konto in den richtigen Kennzahlen. ### Kontozuordnung und Kennzahlen -| Kz | ELSTER-Bezeichnung | Quelle | Erläuterung | +| Kz | Bezeichnung | Quelle | Erläuterung | |---|---|---|---| | **81** | Steuerpfl. Umsätze 19 % | `SUM(betrag_netto)` WHERE `ustva_kz='81'` | 8400, 8570, 8720, 8801, 8820, 8922 | +| **83** | USt auf steuerpfl. Umsätze 19 % | `SUM(betrag_ust)` WHERE `ustva_kz='81'` | s.o. | | **86** | Steuerpfl. Umsätze 7 % | `SUM(betrag_netto)` WHERE `ustva_kz='86'` | 8300, 8710, 8921 | -| **41** | Steuerfreie ig. Lieferungen (§ 4 Nr. 1b) | kontobasiert (`ustva_kz='41'`) **+** `steuerart='ig_lieferung'` | 8125; oder Buchung mit steuerart | -| **43** | Steuerfreie Ausfuhren (§ 4 Nr. 1a) | kontobasiert (`ustva_kz='43'`) **+** `steuerart='export'` | 8120; oder Buchung mit steuerart | -| **48** | Steuerfreie Umsätze ohne Vorsteuerabzug | `SUM(betrag_netto)` WHERE `ustva_kz='48'` | 8100, 8105, 8110 | +| **84** | USt auf steuerpfl. Umsätze 7 % | `SUM(betrag_ust)` WHERE `ustva_kz='86'` | s.o. | +| **35** | Steuerfreie Umsätze | `SUM(betrag_netto)` WHERE `ustva_kz='35'` | 8100, 8105, 8110 | +| **41** | Steuerfreie ig. Lieferungen | kontobasiert (`ustva_kz='41'`) **+** `steuerart='ig_lieferung'` | 8125; oder Buchung mit steuerart | +| **44** | Steuerfreie Ausfuhren | kontobasiert (`ustva_kz='44'`) **+** `steuerart='export'` | 8120; oder Buchung mit steuerart | +| **61** | Vorsteuer 19 % | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND ROUND(ust_satz)=19` | buchungsbasiert – kein spezielles VSt19-Konto nötig | +| **62** | Vorsteuer 7 % | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND ROUND(ust_satz)=7` | buchungsbasiert – kein spezielles VSt7-Konto nötig | | **89** | Ig. Erwerb 19 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_19'` | kein spezielles Konto nötig | +| **91** | USt auf ig. Erwerb 19 % | `Kz 89 × 0,19` | berechnet | | **93** | Ig. Erwerb 7 % – Bemessungsgrundlage | `SUM(betrag_netto)` WHERE `steuerart='ig_erwerb_7'` | kein spezielles Konto nötig | -| **66** | Vorsteuer aus Lieferungen/Leistungen (§§ 14, 14a) | `SUM(betrag_ust)` WHERE `buchungstyp='Ausgabe' AND betrag_ust != 0` | buchungsbasiert – alle inländischen VSt-Beträge unabhängig vom Satz | -| **61** | Vorsteuer aus ig. Erwerben (§ 3d) | `Kz 89 × 0,19 + Kz 93 × 0,07` | berechnet; ig. Erwerb ist USt-neutral (hebt sich in Kz 83 auf) | -| **62** | Vorsteuer aus Einfuhrumsatzsteuer | 0 | in diesem System nicht vorgesehen | -| **67** | Vorsteuer aus Leistungen nach § 13b | 0 | in diesem System nicht vorgesehen | -| **83** | Verbleibende Umsatzsteuer-Vorauszahlung / Überschuss | `ust19 + ust7 − Kz 66 − Kz 61` | Ergebnis; ust19/ust7 = intern aus `betrag_ust` von 81/86-Konten | +| **94** | USt auf ig. Erwerb 7 % | `Kz 93 × 0,07` | berechnet | -### Kennziffern-Bedeutung (ELSTER) +### Abgeleitete Kennzahlen -Kz 83 ist laut ELSTER-Hilfe das **Ergebnis der Voranmeldung** (Verbleibende Vorauszahlung oder Überschuss), nicht – wie in früheren Systemversionen – die USt auf 19 %-Umsätze. Die ELSTER-Kennziffern 83, 61, 62, 66, 67 wurden mit dieser Änderung (2026-04-26) auf die offiziellen ELSTER-Definitionen korrigiert. +| Kz | Bezeichnung | Berechnung | +|---|---|---| +| **67** | Vorsteuer aus ig. Erwerb | Kz 91 + Kz 94 | +| **66** | Vorsteuerbeträge gesamt | Kz 61 + Kz 62 + Kz 67 | +| **69** | Verbleibende USt-Vorauszahlung / Erstattung | (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 | -Kz 35 und Kz 44 wurden in Migration 017 auf Kz 48 (steuerfreie Umsätze ohne VSt-Abzug) bzw. Kz 43 (Ausfuhren) umgestellt und werden nicht mehr verwendet. +### Migration 022 – Vorsteuer-Kz-Aufteilung (historisch) + +Vor Migration 022 trugen alle Vorsteuer-Konten `ustva_kz='66'`. Migration 022 setzte `ustva_kz='61'/'62'` je nach `steuercode`. Diese kontobasierte Zuordnung war erforderlich, weil alle Ausgaben eines Kontos denselben USt-Satz hatten. + +### Buchungsbasierte Vorsteuer (ab 2026) + +Die kontobasierte Zuordnung über `ustva_kz='61'/'62'` wurde zugunsten einer buchungsbasierten Zuordnung aufgegeben: `UstvaCalculator` wertet jetzt direkt `bookings.ust_satz` aus (`buchungstyp='Ausgabe'`, `betrag_ust != 0`). Damit reicht ein einzelnes Aufwandskonto für 19 % und 7 % USt aus. Der Wert `ustva_kz='61'/'62'` auf Konten wird nicht mehr ausgewertet. + +Kz 66 wird weiterhin rechnerisch als `Kz 61 + Kz 62 + Kz 67` aggregiert. diff --git a/src/Controllers/UstvaController.php b/src/Controllers/UstvaController.php index 1cc29eb..059c174 100644 --- a/src/Controllers/UstvaController.php +++ b/src/Controllers/UstvaController.php @@ -104,7 +104,7 @@ class UstvaController $month = (int)$params['month']; $kz = $params['kz'] ?? ''; - $validKz = ['81','86','41','43','48','89','93','66','61','62','67','83']; + $validKz = ['81','83','86','84','35','41','44','89','91','93','94','61','62','67','66','69']; if (!in_array($kz, $validKz, true)) { View::redirect("/tax/ustva/{$year}/{$month}", 'Ungültige Kennzahl.', 'error'); return; diff --git a/src/Services/ElsterXmlService.php b/src/Services/ElsterXmlService.php index 54d2033..d61f249 100644 --- a/src/Services/ElsterXmlService.php +++ b/src/Services/ElsterXmlService.php @@ -114,12 +114,12 @@ class ElsterXmlService $ustva->appendChild($dom->createElement('Steuernummer', $steuernummer)); // Kz-Felder: Bemessungsgrundlagen ganzzahlig, Steuerbeträge mit 2 Dezimalstellen. - // Kz83 (Vorauszahlung/Überschuss) wird aus den Komponenten von ELSTER berechnet, - // muss aber dennoch mit übertragen werden. + // Kz83/Kz84 werden nicht übertragen (serverseitig aus Kz81/Kz86 abgeleitet). + // Kz69 (Zahllast) wird als Kz39 übertragen. $kzBetrag = fn(float $v): string => (string)(int)round($v); $kzSteuer = fn(float $v): string => number_format($v, 2, '.', ''); - $betragsfelder = ['81', '86', '41', '43', '48', '89', '93']; + $betragsfelder = ['81', '86', '35', '41', '44', '89', '93']; foreach ($betragsfelder as $nr) { $val = (float)($kz[$nr]['betrag'] ?? 0.0); if ($val != 0.0) { @@ -127,7 +127,7 @@ class ElsterXmlService } } - $steuerfelder = ['66', '61', '62', '67']; + $steuerfelder = ['61', '62', '67', '66']; foreach ($steuerfelder as $nr) { $val = (float)($kz[$nr]['steuer'] ?? 0.0); if ($val != 0.0) { @@ -135,10 +135,10 @@ class ElsterXmlService } } - // Kz83: Verbleibende Umsatzsteuer-Vorauszahlung (positiv) / Überschuss (negativ) - $kz83val = (float)($kz['83']['steuer'] ?? 0.0); - if ($kz83val != 0.0) { - $ustva->appendChild($dom->createElement('Kz83', $kzSteuer($kz83val))); + // Zahllast → Kz39 (nur bei positivem Wert; bei Erstattung weglassen, da kein Vorzeichen erlaubt) + $zahllast = (float)($kz['69']['steuer'] ?? 0.0); + if ($zahllast > 0.0) { + $ustva->appendChild($dom->createElement('Kz39', $kzSteuer($zahllast))); } return $dom->saveXML(); diff --git a/src/Services/UstvaCalculator.php b/src/Services/UstvaCalculator.php index ad9c253..6349af8 100644 --- a/src/Services/UstvaCalculator.php +++ b/src/Services/UstvaCalculator.php @@ -9,72 +9,76 @@ use Steuerkram\Database; /** * Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung. * - * Drei Quellen fließen in die Berechnung ein: + * Zwei Quellen fließen in die Berechnung ein: * * 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos): - * ustva_kz = '81' → Kz 81 (betrag_netto); intern: ust19 (betrag_ust) - * ustva_kz = '86' → Kz 86 (betrag_netto); intern: ust7 (betrag_ust) + * ustva_kz = '81' → Kz 81 (betrag_netto), Kz 83 (betrag_ust) + * ustva_kz = '86' → Kz 86 (betrag_netto), Kz 84 (betrag_ust) + * ustva_kz = '35' → Kz 35 (betrag_netto) * ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto] - * ustva_kz = '43' → Kz 43 (betrag_netto) [steuerfreie Ausfuhren § 4 Nr. 1a] - * ustva_kz = '48' → Kz 48 (betrag_netto) [steuerfreie Umsätze ohne Vorsteuerabzug] + * ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto] + * (Kz 61/62 werden NICHT mehr über ustva_kz geroutet) * - * 2. Buchungsbasiert (buchungstyp='Ausgabe', betrag_ust ≠ 0): - * Alle inländischen Eingangsrechnungen → Kz 66 (§§ 14, 14a UStG) + * 2. Buchungsbasiert (bookings.ust_satz, für Vorsteuer): + * buchungstyp = 'Ausgabe', ust_satz = 19 → Kz 61 (betrag_ust) + * buchungstyp = 'Ausgabe', ust_satz = 7 → Kz 62 (betrag_ust) + * Damit entfällt die Notwendigkeit separater VSt19/VSt7-Konten. * * 3. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto): * steuerart = 'ig_lieferung' → Kz 41 (betrag_netto) - * steuerart = 'export' → Kz 43 (betrag_netto) - * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto) - * steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto) + * steuerart = 'export' → Kz 44 (betrag_netto) + * steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto), Kz 91 (netto × 0,19) + * steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto), Kz 94 (netto × 0,07) * Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung * ausgeschlossen, um Doppelzählung zu vermeiden. + * Buchungen mit EU-Steuerarten fließen ebenfalls NICHT in Kz 61/62. * * Abgeleitete Kennzahlen: - * Kz 61 = Kz89 × 0,19 + Kz93 × 0,07 (Vorsteuer aus ig. Erwerben, § 3d UStG) - * Kz 62 = 0 (Einfuhrumsatzsteuer – in diesem System n.v.) - * Kz 67 = 0 (§ 13b-Vorsteuer – in diesem System n.v.) - * Kz 83 = ust19 + ust7 − Kz66 − Kz61 − Kz62 − Kz67 - * = ust19 + ust7 − Kz66 (ig. Erwerb ist USt-neutral) + * Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb) + * Kz 66 = Kz 61 + Kz 62 + Kz 67 (Vorsteuer gesamt) + * Kz 69 = (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 (Zahllast) */ class UstvaCalculator { /** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */ private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7']; + /** Vorsteuer-Kennzahl je USt-Satz (buchungsbasiert). */ + private const VST_SATZ_KZ = [19 => '61', 7 => '62']; + /** - * Kontobasiertes Drill-down-Mapping. - * Kz 83 (Zahllast) enthält auch die Einnahmen-Konten 81/86. + * Welche accounts.ustva_kz-Werte liefern Buchungen für die Drill-down-Ansicht. + * Nur für den kontobasierten Teil; EU-Steuerarten kommen aus KZ_STEUERART_MAP. + * Kz 61/62 sind hier nicht mehr enthalten (buchungsbasiert via VST_SATZ_KZ). */ private const KZ_ACCOUNT_MAP = [ '81' => ['81'], + '83' => ['81'], '86' => ['86'], + '84' => ['86'], + '35' => ['35'], '41' => ['41'], - '43' => ['43'], - '48' => ['48'], - '83' => ['81', '86'], + '44' => ['44'], ]; /** - * Steuerart-basiertes Drill-down-Mapping. + * Welche steuerart-Werte liefern Buchungen für eine Kennzahl. */ private const KZ_STEUERART_MAP = [ '41' => ['ig_lieferung'], - '43' => ['export'], + '44' => ['export'], '89' => ['ig_erwerb_19'], + '91' => ['ig_erwerb_19'], '93' => ['ig_erwerb_7'], - '61' => ['ig_erwerb_19', 'ig_erwerb_7'], - '83' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'], + '94' => ['ig_erwerb_7'], + '67' => ['ig_erwerb_19', 'ig_erwerb_7'], + '66' => ['ig_erwerb_19', 'ig_erwerb_7'], + '69' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'], ]; - /** - * Kennzahlen, deren Drill-down alle inländischen Ausgaben mit Vorsteuer enthält. - * (buchungsbasiert: buchungstyp='Ausgabe', betrag_ust != 0, keine EU-Steuerarten) - */ - private const KZ_INCLUDES_VST = ['66', '83']; - public function calculate(int $year, int $month): array { - // ── 1. Kontobasierte Kennzahlen (EU-Steuerarten ausgeschlossen) ────────── + // ── 1. Kontobasierte Kennzahlen (EU-Steuerarten und Kz 61/62 ausgeschlossen) ── $euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?')); $accountRows = Database::fetchAll( 'SELECT a.ustva_kz, @@ -85,6 +89,7 @@ class UstvaCalculator JOIN accounts a ON a.id = bk.kategorie_konto_id WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? AND a.ustva_kz IS NOT NULL AND a.ustva_kz != \'\' + AND a.ustva_kz NOT IN (\'61\', \'62\') AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . ')) GROUP BY a.ustva_kz', array_merge([$year, $month], self::EU_STEUERARTEN) @@ -98,19 +103,26 @@ class UstvaCalculator ]; } - // ── 2. Buchungsbasierte Vorsteuer Kz 66 (alle inländischen Eingangsrechnungen) ── - $vstRow = Database::fetchOne( - 'SELECT SUM(bk.betrag_ust) AS summe_ust + // ── 2. Buchungsbasierte Vorsteuer Kz 61/62 (via ust_satz) ──────────── + $vstRows = Database::fetchAll( + 'SELECT bk.ust_satz, SUM(bk.betrag_ust) AS summe_ust FROM bookings bk JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ? AND bk.buchungstyp = \'Ausgabe\' AND bk.betrag_ust != 0 - AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))', + AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . ')) + GROUP BY bk.ust_satz', array_merge([$year, $month], self::EU_STEUERARTEN) ); - $byKz['66'] = ['netto' => 0.0, 'ust' => (float)($vstRow['summe_ust'] ?? 0.0)]; + foreach ($vstRows as $row) { + $satz = (int)round((float)$row['ust_satz']); + $kzNr = self::VST_SATZ_KZ[$satz] ?? null; + if ($kzNr !== null) { + $byKz[$kzNr] = ['netto' => 0.0, 'ust' => (float)$row['summe_ust']]; + } + } // ── 3. Steuerart-basierte Kennzahlen ───────────────────────────────── $stRows = Database::fetchAll( @@ -129,41 +141,42 @@ class UstvaCalculator } // ── 4. Kennzahlen zusammensetzen ───────────────────────────────────── - $n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0); - $u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0); - $st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0); + $n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0); + $u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0); + $st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0); - $kz81 = $n('81'); - $ust19 = $u('81'); // interne USt aus 19 %-Umsätzen (kein eigenständiges ELSTER-Kz) - $kz86 = $n('86'); - $ust7 = $u('86'); // interne USt aus 7 %-Umsätzen (kein eigenständiges ELSTER-Kz) - $kz41 = $n('41') + $st('ig_lieferung'); - $kz43 = $n('43') + $st('export'); - $kz48 = $n('48'); - $kz89 = $st('ig_erwerb_19'); - $kz93 = $st('ig_erwerb_7'); - $kz66 = $u('66'); // § 14, 14a - $kz61 = round($kz89 * 0.19, 2) + round($kz93 * 0.07, 2); // § 3d Satz 1 - $kz62 = 0.0; // Einfuhrumsatzsteuer n.v. - $kz67 = 0.0; // § 13b-Vorsteuer n.v. - // ig. Erwerb-USt gilt gleichzeitig als Output- und als Vorsteuer (kz61) → neutral - $igErwerbUst = round($kz89 * 0.19, 2) + round($kz93 * 0.07, 2); // = kz61 - $kz83 = ($ust19 + $ust7 + $igErwerbUst) - ($kz66 + $kz61 + $kz62 + $kz67); + $kz81 = $n('81'); $kz83 = $u('81'); + $kz86 = $n('86'); $kz84 = $u('86'); + $kz35 = $n('35'); + $kz41 = $n('41') + $st('ig_lieferung'); + $kz44 = $n('44') + $st('export'); + $kz89 = $st('ig_erwerb_19'); + $kz91 = round($kz89 * 0.19, 2); + $kz93 = $st('ig_erwerb_7'); + $kz94 = round($kz93 * 0.07, 2); + $kz61 = $u('61'); + $kz62 = $u('62'); + $kz67 = $kz91 + $kz94; + $kz66 = $kz61 + $kz62 + $kz67; + $kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66; return [ - '81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], - '86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => round($kz86, 2), 'steuer' => null], - '43' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz43, 2), 'steuer' => null], - '48' => ['name' => 'Steuerfreie Umsätze ohne Vorsteuerabzug', 'betrag' => round($kz48, 2), 'steuer' => null], - '41' => ['name' => 'Steuerfreie ig. Lieferungen (§ 4 Nr. 1b)', 'betrag' => round($kz41, 2), 'steuer' => null], - '89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null], - '93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null], - '66' => ['name' => 'Vorsteuer aus Lieferungen/Leistungen (§§ 14, 14a)', 'betrag' => null, 'steuer' => round($kz66, 2)], - '61' => ['name' => 'Vorsteuer aus ig. Erwerben (§ 3d UStG)', 'betrag' => null, 'steuer' => round($kz61, 2)], - '62' => ['name' => 'Vorsteuer aus Einfuhrumsatzsteuer', 'betrag' => null, 'steuer' => round($kz62, 2)], - '67' => ['name' => 'Vorsteuer aus Leistungen nach § 13b UStG', 'betrag' => null, 'steuer' => round($kz67, 2)], - '83' => ['name' => 'Verbleibende Umsatzsteuer-Vorauszahlung / Überschuss', 'betrag' => null, 'steuer' => round($kz83, 2)], - '_ust' => ['name' => 'Umsatzsteuer gesamt (intern)', 'betrag' => null, 'steuer' => round($ust19 + $ust7, 2)], + '81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null], + '83' => ['name' => 'USt auf steuerpfl. Umsätze 19 %', 'betrag' => null, 'steuer' => round($kz83, 2)], + '86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => round($kz86, 2), 'steuer' => null], + '84' => ['name' => 'USt auf steuerpfl. Umsätze 7 %', 'betrag' => null, 'steuer' => round($kz84, 2)], + '35' => ['name' => 'Steuerfreie Umsätze (ohne Vorsteuerabzug)', 'betrag' => round($kz35, 2), 'steuer' => null], + '41' => ['name' => 'Steuerfreie ig. Lieferungen (§ 4 Nr. 1b)', 'betrag' => round($kz41, 2), 'steuer' => null], + '44' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz44, 2), 'steuer' => null], + '89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null], + '91' => ['name' => 'USt auf ig. Erwerb 19 %', 'betrag' => null, 'steuer' => round($kz91, 2)], + '93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null], + '94' => ['name' => 'USt auf ig. Erwerb 7 %', 'betrag' => null, 'steuer' => round($kz94, 2)], + '61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => round($kz61, 2)], + '62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => round($kz62, 2)], + '67' => ['name' => 'Vorsteuer aus ig. Erwerb', 'betrag' => null, 'steuer' => round($kz67, 2)], + '66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => round($kz66, 2)], + '69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => round($kz69, 2)], ]; } @@ -173,11 +186,13 @@ class UstvaCalculator */ public function fetchBookingsForKz(int $year, int $month, string $kz): array { - $accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? []; - $steuerartList = self::KZ_STEUERART_MAP[$kz] ?? []; - $includesVst = in_array($kz, self::KZ_INCLUDES_VST, true); + $accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? []; + $steuerartList = self::KZ_STEUERART_MAP[$kz] ?? []; - if (empty($accountKzList) && empty($steuerartList) && !$includesVst) { + // Kz 61/62: buchungsbasiert via ust_satz + $vstSatz = array_search($kz, self::VST_SATZ_KZ, true); + + if (empty($accountKzList) && empty($steuerartList) && $vstSatz === false) { return []; } @@ -211,14 +226,16 @@ class UstvaCalculator ); } - if ($includesVst) { + if ($vstSatz !== false) { $euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?')); $parts[] = $select . ' AND bk.buchungstyp = \'Ausgabe\'' . ' AND bk.betrag_ust != 0' + . ' AND ROUND(bk.ust_satz) = ?' . ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))'; $params = array_merge($params, [$year, $month], + [$vstSatz], self::EU_STEUERARTEN ); } diff --git a/src/Views/tax/ustva_kz_bookings.php b/src/Views/tax/ustva_kz_bookings.php index 717adb2..fa386d5 100644 --- a/src/Views/tax/ustva_kz_bookings.php +++ b/src/Views/tax/ustva_kz_bookings.php @@ -3,10 +3,10 @@ use Steuerkram\View; $h = fn($v) => View::h($v); $fmtKz = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; -// Für Kz 83: vorzeichenbehafteter USt-Beitrag zur Zahllast. +// Für Kz 69: vorzeichenbehafteter USt-Beitrag zur Zahllast. // Einnahmen mit USt = positiv, Ausgaben mit Vorsteuer = negativ, // ig_erwerb heben sich gegenseitig auf → 0. -$ustBeitragKz83 = function(array $b): float { +$ustBeitragKz69 = function(array $b): float { $steuerart = $b['steuerart'] ?? 'normal'; $ustSatz = (float)$b['ust_satz']; $betragUst = (float)$b['betrag_ust']; @@ -16,7 +16,7 @@ $ustBeitragKz83 = function(array $b): float { if ($b['buchungstyp'] === 'Ausgabe' && $steuerart === 'normal' && $betragUst != 0) { return -$betragUst; // Vorsteuer → Vorzeichen umkehren } - return 0.0; // steuerfreie Umsätze und ig_erwerb (Netto-Beitrag 0, da neutral) + return 0.0; // steuerfreie Umsätze und ig_erwerb (KZ91-KZ94 = KZ67, Netto-Beitrag 0) }; // Ausgaben werden als negative Beträge angezeigt (Geld fließt ab). @@ -49,7 +49,7 @@ $signedBetrag = fn(array $b, string $field): float => Kategorie Zahlungskonto Netto - + Brutto Anlage @@ -60,7 +60,7 @@ $signedBetrag = fn(array $b, string $field): float => $sumUst = 0.0; $sumBrutto = 0.0; foreach ($bookings as $b): - $ust = $kz === '83' ? $ustBeitragKz83($b) : $signedBetrag($b, 'betrag_ust'); + $ust = $kz === '69' ? $ustBeitragKz69($b) : $signedBetrag($b, 'betrag_ust'); $netto = $signedBetrag($b, 'betrag_netto'); $brutto = $signedBetrag($b, 'betrag_brutto'); $sumNetto += $netto; diff --git a/src/Views/tax/ustva_show.php b/src/Views/tax/ustva_show.php index 3ebaad9..5bcdc3f 100644 --- a/src/Views/tax/ustva_show.php +++ b/src/Views/tax/ustva_show.php @@ -30,10 +30,11 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' ['81', '86', '43', '48', '41'], - 'Innergemeinschaftlicher Erwerb' => ['89', '93'], - 'Abziehbare Vorsteuerbeträge' => ['66', '61', '62', '67'], - 'Verbleibende Zahllast' => ['83'], + 'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'], + 'Umsatzsteuer auf Umsätze' => ['83', '84'], + 'Innergemeinschaftlicher Erwerb' => ['89', '91', '93', '94'], + 'Vorsteuerbeträge' => ['61', '62', '67', '66'], + 'Verbleibende Zahllast' => ['69'], ]; foreach ($sections as $sectionTitle => $kzNrs): ?> @@ -45,7 +46,7 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' @@ -108,16 +109,16 @@ $fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' USt gesamt - + Vorsteuer – - = 0 ? 'Vorauszahlung' : 'Erstattung' ?> - - + = 0 ? 'Vorauszahlung' : 'Erstattung' ?> + + diff --git a/tests/05_TaxSummenTest.php b/tests/05_TaxSummenTest.php index 4d90f6b..ef35c1c 100644 --- a/tests/05_TaxSummenTest.php +++ b/tests/05_TaxSummenTest.php @@ -111,15 +111,30 @@ class TaxSummenTest extends TestCase 'UStVA Kz 81 (Umsätze 19%): Betrag enthält Einnahme ≥ 500 €' ); - // Kz 66: reguläre Inlandsvorsteuer (§§ 14, 14a) – muss mindestens 38 € enthalten + // Kz 83: USt auf Umsätze 19% – abgeleitet (19% von Kz81-Betrag) $this->assert( - (float)$kz['66']['steuer'] >= 38.00, - 'UStVA Kz 66 (Vorsteuer §§ 14, 14a): Steuerbetrag enthält Ausgabe ≥ 38 €' + (float)$kz['83']['steuer'] >= 95.00, + 'UStVA Kz 83 (USt 19%): Steuerbetrag ≥ 95 €' ); - // Kz 83: Zahllast = _ust - Kz66 (USt gesamt minus Vorsteuer) - $erwKz83 = round((float)$kz['_ust']['steuer'] - (float)$kz['66']['steuer'], 2); - $this->assertEquals($erwKz83, (float)$kz['83']['steuer'], 'UStVA Kz 83 (Zahllast) korrekt berechnet'); + // Kz 61: Vorsteuer 19% – muss mindestens 38 € enthalten + $this->assert( + (float)$kz['61']['steuer'] >= 38.00, + 'UStVA Kz 61 (Vorsteuer 19%): Steuerbetrag enthält Ausgabe ≥ 38 €' + ); + + // Kz 66: Vorsteuer gesamt – muss ≥ Kz 61 sein + $this->assert( + (float)$kz['66']['steuer'] >= (float)$kz['61']['steuer'], + 'UStVA Kz 66 (Vorsteuer gesamt): ≥ Kz 61' + ); + + // Kz 69 Berechnung: USt gesamt - Vorsteuer gesamt + $erwKz69 = round( + ((float)$kz['83']['steuer'] + (float)$kz['84']['steuer']) - (float)$kz['66']['steuer'], + 2 + ); + $this->assertEquals($erwKz69, (float)$kz['69']['steuer'], 'UStVA Kz 69 (Zahllast) korrekt berechnet'); // ── EÜR prüfen ──────────────────────────────────────────────────── $euerCalc = new \Steuerkram\Services\EuerCalculator(); diff --git a/tests/07_UstvaAuslandTest.php b/tests/07_UstvaAuslandTest.php index 6eb872c..92e6ef3 100644 --- a/tests/07_UstvaAuslandTest.php +++ b/tests/07_UstvaAuslandTest.php @@ -6,7 +6,7 @@ declare(strict_types=1); * Test 7: UStVA-Kennzahlen für Auslandsumsätze und ig. Erwerb. * * Prüft, dass steuerart-Buchungen korrekt in die UStVA-Kennzahlen - * 41, 43, 89, 93 und 61 einfließen. + * 41, 44, 89, 91, 93, 94 und 67 einfließen. */ class UstvaAuslandTest extends TestCase { @@ -36,11 +36,6 @@ class UstvaAuslandTest extends TestCase ); $bankId = $bankId ? (int)$bankId['id'] : $zahlungId; - // Baseline Kz83 VOR den Test-Buchungen (reale Buchungen im laufenden Monat) - $calcPre = new \Steuerkram\Services\UstvaCalculator(); - $kzPre = $calcPre->calculate($year, $month); - $kz83Pre = (float)$kzPre['83']['steuer']; - // ── 1. Steuerfreie ig. Lieferung → Kz 41 ────────────────────────── $belegIgL = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGL-' . uniqid(), @@ -63,7 +58,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test ig. Lieferung EU', ]); - // ── 2. Steuerfreie Ausfuhr → Kz 43 ─────────────────────────────── + // ── 2. Steuerfreie Ausfuhr → Kz 44 ─────────────────────────────── $belegExp = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-EXP-' . uniqid(), 'filename' => 'test-exp.pdf', @@ -85,7 +80,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test Ausfuhr Drittland', ]); - // ── 3. Ig. Erwerb 19 % → Kz 89 (Bemessungsgrundlage), Kz 61 (Vorsteuer) ── + // ── 3. Ig. Erwerb 19 % → Kz 89/91/67 ───────────────────────────── $belegIgE19 = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGE19-' . uniqid(), 'filename' => 'test-ige19.pdf', @@ -107,7 +102,7 @@ class UstvaAuslandTest extends TestCase 'buchungstext' => 'Test ig. Erwerb 19%', ]); - // ── 4. Ig. Erwerb 7 % → Kz 93 (Bemessungsgrundlage), Kz 61 (Vorsteuer) ─── + // ── 4. Ig. Erwerb 7 % → Kz 93/94/67 ────────────────────────────── $belegIgE7 = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'TEST-IGE7-' . uniqid(), 'filename' => 'test-ige7.pdf', @@ -139,10 +134,10 @@ class UstvaAuslandTest extends TestCase 'Kz 41 (ig. Lieferung): Betrag ≥ 1.000 €' ); - // Kz 43: Ausfuhr 2000 € + // Kz 44: Ausfuhr 2000 € $this->assert( - (float)$kz['43']['betrag'] >= 2000.00, - 'Kz 43 (Ausfuhr): Betrag ≥ 2.000 €' + (float)$kz['44']['betrag'] >= 2000.00, + 'Kz 44 (Ausfuhr): Betrag ≥ 2.000 €' ); // Kz 89: ig. Erwerb 19% Bemessungsgrundlage 500 € @@ -151,33 +146,52 @@ class UstvaAuslandTest extends TestCase 'Kz 89 (ig. Erwerb 19% Bmgl.): Betrag ≥ 500 €' ); + // Kz 91: USt auf ig. Erwerb 19% = 500 * 0.19 = 95 € + $this->assertEquals( + round(500.00 * 0.19, 2), + (float)$kz['91']['steuer'], + 'Kz 91 (USt ig. Erwerb 19%): Steuer = 95,00 €' + ); + // Kz 93: ig. Erwerb 7% Bemessungsgrundlage 300 € $this->assert( (float)$kz['93']['betrag'] >= 300.00, 'Kz 93 (ig. Erwerb 7% Bmgl.): Betrag ≥ 300 €' ); - // Kz 61: Vorsteuer aus ig. Erwerben (§ 3d) = 500*0.19 + 300*0.07 = 95 + 21 = 116 € - $erwartetKz61 = round(500.00 * 0.19, 2) + round(300.00 * 0.07, 2); + // Kz 94: USt auf ig. Erwerb 7% = 300 * 0.07 = 21 € $this->assertEquals( - $erwartetKz61, - (float)$kz['61']['steuer'], - 'Kz 61 (Vorsteuer ig. Erwerb § 3d): Steuer = 116,00 €' + round(300.00 * 0.07, 2), + (float)$kz['94']['steuer'], + 'Kz 94 (USt ig. Erwerb 7%): Steuer = 21,00 €' ); - // Kz 83 (Zahllast): ig. Erwerb/Lieferung/Ausfuhr sind USt-neutral (betrag_ust=0) - // → Zahllast darf sich gegenüber Baseline nicht verändert haben + // Kz 67: Vorsteuer aus ig. Erwerb = Kz 91 + Kz 94 = 116 € + $erwartetKz67 = round((float)$kz['91']['steuer'] + (float)$kz['94']['steuer'], 2); $this->assertEquals( - round($kz83Pre, 2), - (float)$kz['83']['steuer'], - 'Kz 83 (Zahllast) unverändert – alle EU-Buchungen sind USt-neutral' + $erwartetKz67, + (float)$kz['67']['steuer'], + 'Kz 67 (Vorsteuer ig. Erwerb) = Kz 91 + Kz 94' ); - // ig. Erwerb darf NICHT in Kz 48 landen (steuerfreie Umsätze ohne VSt-Abzug) + // Kz 69 Plausibilitätsprüfung: ig. Erwerb neutralisiert sich durch Kz 67 + // USt auf Umsätze (83+84) + ig. Erwerb (91+94) - Vorsteuer (66) = 69 + $ust = (float)$kz['83']['steuer'] + (float)$kz['84']['steuer'] + + (float)$kz['91']['steuer'] + (float)$kz['94']['steuer']; + $erwartetKz69 = round($ust - (float)$kz['66']['steuer'], 2); + $this->assertEquals( + $erwartetKz69, + (float)$kz['69']['steuer'], + 'Kz 69 (Zahllast) korrekt berechnet' + ); + + // ig. Erwerb darf NICHT in Kz 35 oder Kz 61/62 landen + // (kz35 enthält nur normale 0%-Einnahmen, kz61 nur normale Vorsteuer) + // Wir buchten keine normalen 0%-Einnahmen, also muss Kz 35 = 0 sein $this->assertEquals( 0.0, - (float)$kz['48']['betrag'], - 'Kz 48 (steuerfreie Inlandsumsätze) = 0 (kein steuerfreier Inlandsumsatz gebucht)' + (float)$kz['35']['betrag'], + 'Kz 35 (steuerfreie Inlandsumsätze) = 0 (kein normaler 0%-Umsatz gebucht)' ); } diff --git a/tests/13_ElsterXmlKz39Test.php b/tests/13_ElsterXmlKz39Test.php index 76c61a3..1c4cfc5 100644 --- a/tests/13_ElsterXmlKz39Test.php +++ b/tests/13_ElsterXmlKz39Test.php @@ -6,33 +6,29 @@ use Steuerkram\Services\ElsterXmlService; class ElsterXmlKz39Test extends TestCase { - protected string $testName = 'Test 13: UStVA XML – Kz83 Vorauszahlung/Überschuss'; + protected string $testName = 'Test 13: UStVA XML – Kz39 Vorzeichen-Validierung'; public function run(): void { $service = new ElsterXmlService(); - // Fall 1: Zahllast positiv → Kz83 muss vorhanden und positiv sein - $kzZahllast = ['83' => ['steuer' => 150.00]]; + // Fall 1: Zahllast positiv → Kz39 muss vorhanden und positiv sein + $kzZahllast = ['69' => ['steuer' => 150.00]]; $xml = $service->generate(2026, 1, $kzZahllast); - $this->assert(str_contains($xml, ''), 'Kz83 bei Zahllast vorhanden'); - $this->assert(str_contains($xml, '150.00'), 'Kz83 enthält korrekten Betrag bei Zahllast'); + $this->assert(str_contains($xml, ''), 'Kz39 bei Zahllast vorhanden'); + $this->assert(str_contains($xml, '150.00'), 'Kz39 ohne Vorzeichen bei Zahllast'); - // Fall 2: Erstattung (negativ) → Kz83 muss vorhanden sein (Überschuss) - $kzErstattung = ['83' => ['steuer' => -50.00]]; + // Fall 2: Erstattung (negativ) → Kz39 darf nicht vorkommen + $kzErstattung = ['69' => ['steuer' => -50.00]]; $xml = $service->generate(2026, 1, $kzErstattung); - $this->assert(str_contains($xml, ''), 'Kz83 bei Erstattung vorhanden'); - $this->assert(str_contains($xml, '-50.00'), 'Kz83 enthält negativen Betrag bei Erstattung'); + $this->assert(!str_contains($xml, ''), 'Kz39 bei Erstattung nicht vorhanden'); - // Fall 3: Zahllast = 0 → Kz83 weglassen - $kzNull = ['83' => ['steuer' => 0.0]]; + // Fall 3: Zahllast = 0 → Kz39 weglassen + $kzNull = ['69' => ['steuer' => 0.0]]; $xml = $service->generate(2026, 1, $kzNull); - $this->assert(!str_contains($xml, ''), 'Kz83 bei Nullbetrag nicht vorhanden'); + $this->assert(!str_contains($xml, ''), 'Kz39 bei Nullbetrag nicht vorhanden'); - // Fall 4: Kz39 (Sondervorauszahlung) darf NICHT mehr als Zahllast exportiert werden - $this->assert(!str_contains($xml, ''), 'Kz39 (Sondervorauszahlung) wird nicht exportiert'); - - // Fall 5: XML ist valides XML + // Fall 4: XML ist valides XML $doc = new \DOMDocument(); $loaded = @$doc->loadXML($xml); $this->assert($loaded !== false, 'Generiertes XML ist wohlgeformt'); diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index 366546d..ec9294e 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -93,12 +93,12 @@ class StornoBuchungTest extends TestCase ); // ── UStVA: Vorsteuer-KZ hebt sich auf ───────────────────────── - // Kz 66 enthält alle inländischen Ausgaben mit Vorsteuer (buchungsbasiert). + // Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61'). // Original: +19, Storno: -19 → Summe = 0 (oder weniger als andere Tests einbringen). // Da weitere Tests parallel laufen können, prüfen wir nur, dass die Storno-Buchung // als eigenständige Zeile in fetchBookingsForKz erscheint. $ustvaCalc = new \Steuerkram\Services\UstvaCalculator(); - $bookings = $ustvaCalc->fetchBookingsForKz($year, $month, '66'); + $bookings = $ustvaCalc->fetchBookingsForKz($year, $month, '61'); $reversalFound = false; foreach ($bookings as $b) { @@ -109,6 +109,6 @@ class StornoBuchungTest extends TestCase break; } } - $this->assert($reversalFound, 'UStVA-Drilldown Kz 66: Stornobuchung erscheint in Detailansicht'); + $this->assert($reversalFound, 'UStVA-Drilldown Kz 61: Stornobuchung erscheint in Detailansicht'); } } From 77631a3e5a050da8b03f26cb564de4afefccc054 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 26 Apr 2026 21:17:02 +0200 Subject: [PATCH 14/14] Fix: Eigenanteil als Einnahme, Ollama-Modell auf qwen3-vl, KI-Analyse-Autostart --- Changes.md | 7 +++++++ docker-compose.yml | 2 +- src/Models/Booking.php | 4 ++-- src/Views/belege/detail.php | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Changes.md b/Changes.md index e45ecef..a4e3153 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,13 @@ --- +## 2026-04-26 – Fix: Eigenanteil-Buchung als Einnahme statt Ausgabe + +### Geändert +- `src/Models/Booking.php`: `upsertKorrekturbuchung()` setzt `buchungstyp` jetzt korrekt auf `Einnahme` (war fälschlich `Ausgabe`) + +--- + ## 2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter ### Hinzugefügt diff --git a/docker-compose.yml b/docker-compose.yml index 9f5b121..139dfef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: DB_PASS: steuerkram APP_SECRET: change-this-in-production OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl} extra_hosts: - "host-gateway:host-gateway" depends_on: diff --git a/src/Models/Booking.php b/src/Models/Booking.php index bec17b8..5f29771 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -251,7 +251,7 @@ class Booking self::create([ 'beleg_id' => $belegId, 'buchungs_datum' => $buchungsDatum, - 'buchungstyp' => 'Ausgabe', + 'buchungstyp' => 'Einnahme', 'kategorie_konto_id' => $kategorieId, 'zahlungskonto_id' => $zahlungsId, 'betrag_netto' => $betragNetto, @@ -264,7 +264,7 @@ class Booking self::create([ 'beleg_id' => $belegId, 'buchungs_datum' => $buchungsDatum, - 'buchungstyp' => 'Ausgabe', + 'buchungstyp' => 'Einnahme', 'kategorie_konto_id' => $kategorieId, 'zahlungskonto_id' => $zahlungsId, 'betrag_netto' => $betragNetto, diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 26a7a3b..2660bea 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -973,7 +973,7 @@ async function _startKiAnalyse() { document.addEventListener('DOMContentLoaded', function () { const arr = _normalizeKiArr(_kiVorschlag); if (arr === null) { - // Noch nicht verarbeitet – nichts anzeigen + if (_kiAktiv) { _startKiAnalyse(); } } else if (arr.length === 0) { // Verarbeitet, aber kein Ergebnis _showKiBadgeNoResult();