From 3acd0cea5ce4be40849bf0c1aad7b7ecefd13539 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 10 Aug 2026 21:13:11 +0200 Subject: [PATCH 1/3] Fix #156: Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben --- Changes.md | 12 + src/Controllers/AccountController.php | 60 +--- src/Controllers/BankController.php | 9 +- src/Controllers/BelegController.php | 5 +- src/Controllers/BookingController.php | 63 +--- src/Controllers/CustomerController.php | 6 +- src/Controllers/InvoiceController.php | 47 +-- src/Controllers/UstvaController.php | 27 +- src/Models/BankTransaction.php | 17 + src/Models/Beleg.php | 41 +++ src/Models/Booking.php | 99 +++++ src/Models/FiscalYear.php | 6 + src/Models/Invoice.php | 77 ++++ src/Models/UstvaSubmission.php | 44 +++ tests/85_ModellAbfragenTest.php | 478 +++++++++++++++++++++++++ 15 files changed, 812 insertions(+), 179 deletions(-) create mode 100644 src/Models/UstvaSubmission.php create mode 100644 tests/85_ModellAbfragenTest.php diff --git a/Changes.md b/Changes.md index fc9bef7..f377105 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-08-10 – Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben (#156) + +- 34 direkte `Database::`-Aufrufe in `AccountController`, `InvoiceController`, `BookingController`, `UstvaController`, `BankController`, `BelegController` und `CustomerController` verletzten die MVC-Schichtgrenze (`CLAUDE.md`: SQL ausschließlich über `Database::` *innerhalb* der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar. `AssetController.php` bleibt unverändert – die dort verbleibenden vier Aufrufe liegen außerhalb des Umfangs von #155 und sind laut Issue #156 nicht Teil dieses Tickets. +- **`src/Models/Booking.php`:** Neue Methoden `dashboardKennzahlen()` (offene Rechnungen, ungebuchte Belege – bislang ungetestete Dashboard-Kennzahlen), `recent(int $limit)`, `journalForAccount(int $accountId, int $year)`, `deleteHard(int $id)` sowie `bucheBewirtungssplit()` (aus `BookingController` verschoben, vgl. Q3 im Issue: Split-Fachlogik gehört zu `Booking`, dort existiert bereits der verwandte Helfer `upsertKorrekturbuchung()`). +- **`src/Models/FiscalYear.php`:** Neue Methode `registeredYears()` (Geschäftsjahre aus `fiscal_years`, für das Konten-Journal – die bestehende `availableYears()` liest stattdessen `DISTINCT YEAR()` aus `bookings` und passte inhaltlich nicht). +- **`src/Models/Invoice.php`:** Neue Methoden `setBelegId()`, `findStorno()`, `findReference()`, `markSent()`, `deleteLine()`, `forCustomer()` sowie `finalizeWithBeleg()` (bündelt die bisher im Controller offene Transaktion aus Finalisierung + Beleg-Erzeugung; die eigentliche PDF-Erstellung bleibt als Callback beim Aufrufer, da sie Datei-I/O und keine reine Datenbank-Operation ist). +- **`src/Models/Beleg.php`:** Neue Methoden `updateBewirtung()`, `updateEigenanteil30()`, `updateBetragBrutto()`, `updateStorage()` sowie `setSupplierId()` (identisch zu `updateSupplier()`, aber bewusst ohne `locked`-Prüfung – wird beim automatischen Vorbelegen des Lieferanten aus einer Bankbuchung verwendet). +- **`src/Models/BankTransaction.php`:** Neue Methode `importBatch()` bündelt Batch-Anlage, Zeilen-Import und Transaktionsklammer (vorher offen in `BankController::doImport()`). +- **`src/Models/UstvaSubmission.php`** (neu): `all()`, `find()`, `upsert()` für `ustva_submissions`, bisher nur als Inline-SQL in `UstvaController`. +- Alle verschobenen SQL-Statements wurden unverändert übernommen; keine Verhaltensänderung außer der bewusst parametrisierten `LIMIT` in `Booking::recent()` (vorher fest `LIMIT 10`, jetzt Parameter mit Default 10) und der Zusammenfassung der Transaktionsklammern in die jeweiligen Modellmethoden. +- **`tests/85_ModellAbfragenTest.php`** (neu): Deckt alle oben genannten Modellmethoden anhand bekannter Testdaten ab, Schwerpunkt auf `Booking::dashboardKennzahlen()`. Regressionsschutz am Ende: `grep`-Äquivalent, das außerhalb der dokumentierten `AssetController`-Ausnahme keinen `Database::`-Aufruf mehr in `src/Controllers/` toleriert. + ## 2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149) - `BookingController::store()` schreibt eine nicht auflösbare Freitext-Kontonummer (`kategorie_kontonummer`/`zahlungs_kontonummer`) ungefiltert in `$errors[...]`. Die Ausgabestelle in `src/Views/belege/detail.php:419` escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit `$h()` – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad. diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php index 02d10d1..405bc2c 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -7,6 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Account; use Steuerkram\Models\Booking; use Steuerkram\Models\Beleg; +use Steuerkram\Models\FiscalYear; use Steuerkram\Services\EuerCalculator; use Steuerkram\View; @@ -50,37 +51,14 @@ class AccountController extends BaseController $stats['month_label'] = $monthNames[$month - 1]; $stats['vormonat_label'] = $monthNames[$prevMonth - 1]; - // Offene Rechnungen - // Bei Teilzahlungen zählt nur der noch offene Restbetrag - $offen = \Steuerkram\Database::fetchOne( - 'SELECT COUNT(*) as cnt, - COALESCE(SUM(i.betrag_brutto - COALESCE(( - SELECT SUM(bk.betrag_brutto) FROM bookings bk - WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\' - AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL - ), 0)), 0) as total - FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')' - ); - $stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0); - $stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0); - - // Ungebuchte Belege - $ungebucht = \Steuerkram\Database::fetchOne( - 'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)' - ); - $stats['ungebuchte_belege'] = (int)($ungebucht['cnt'] ?? 0); + // Offene Rechnungen, ungebuchte Belege + $kennzahlen = Booking::dashboardKennzahlen(); + $stats['offene_rechnungen'] = $kennzahlen['offene_rechnungen']; + $stats['offene_rechnungen_sum'] = $kennzahlen['offene_rechnungen_sum']; + $stats['ungebuchte_belege'] = $kennzahlen['ungebuchte_belege']; // Letzte Buchungen - $recentBookings = \Steuerkram\Database::fetchAll( - 'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name, - z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name, - bl.beleg_nr - FROM bookings bk - JOIN accounts k ON bk.kategorie_konto_id = k.id - JOIN accounts z ON bk.zahlungskonto_id = z.id - JOIN belege bl ON bk.beleg_id = bl.id - ORDER BY bk.created_at DESC LIMIT 10' - ); + $recentBookings = Booking::recent(10); $view = new View(); $view->render('layout/base.php', [ @@ -268,9 +246,7 @@ class AccountController extends BaseController $account = Account::find($id); if (!$account) { http_response_code(404); return; } - $availableYears = \Steuerkram\Database::fetchAll( - 'SELECT year FROM fiscal_years ORDER BY year DESC' - ); + $availableYears = FiscalYear::registeredYears(); $years = array_column($availableYears, 'year'); if (empty($years)) { $years = [(int)date('Y')]; @@ -283,25 +259,7 @@ class AccountController extends BaseController // Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen) // ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto) - $bookings = \Steuerkram\Database::fetchAll( - 'SELECT bk.*, - k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, - z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name, - bl.beleg_nr - FROM bookings bk - JOIN accounts k ON bk.kategorie_konto_id = k.id - JOIN accounts z ON bk.zahlungskonto_id = z.id - LEFT JOIN belege bl ON bk.beleg_id = bl.id - JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id - WHERE fy.year = ? - AND ( - bk.kategorie_konto_id = ? - OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL - AND bk.kategorie_konto_id != ?) - ) - ORDER BY bk.buchungs_datum, bk.id', - [$year, $id, $id, $id] - ); + $bookings = Booking::journalForAccount($id, $year); $view = new View(); $view->render('layout/base.php', [ diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index dc7036a..ead539b 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -10,7 +10,6 @@ use Steuerkram\Models\BankProvider; use Steuerkram\Models\BankSource; use Steuerkram\Models\BankTransaction; use Steuerkram\Controllers\InvoiceController; -use Steuerkram\Database; use Steuerkram\Models\Beleg; use Steuerkram\Models\Invoice; use Steuerkram\Services\BankImportParser; @@ -202,15 +201,9 @@ class BankController extends BaseController private function doImport(int $sourceId, string $filename, array $rows): void { - Database::beginTransaction(); try { - $batchId = BankTransaction::createBatch($sourceId, $filename, count($rows)); - foreach ($rows as $row) { - BankTransaction::insertRow($batchId, $row); - } - Database::commit(); + $batchId = BankTransaction::importBatch($sourceId, $filename, $rows); } catch (\Throwable $e) { - Database::rollBack(); View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error'); } diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index f7a17dc..69bf435 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -291,10 +291,7 @@ class BelegController extends BaseController // Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') { $supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier); - \Steuerkram\Database::execute( - 'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?', - [$supplierId, (int)$params['id']] - ); + Beleg::setSupplierId((int)$params['id'], $supplierId); $beleg['supplier_name'] = $bankTxSupplier; } } diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index 559824c..2edcdc2 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -100,7 +100,7 @@ class BookingController extends BaseController if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang $nr = trim($data[$field] ?? ''); if ($nr !== '') { - $acc = \Steuerkram\Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]); + $acc = Account::findByKontonummer($nr); if (!$acc) { $errors[$idField] = "Kontonummer '$nr' nicht gefunden."; } else { @@ -131,20 +131,14 @@ class BookingController extends BaseController $data['beleg_id'] = $belegId; // Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung) - \Steuerkram\Database::execute( - 'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?', - [ - !empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null, - !empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null, - $belegId, - ] + Beleg::updateBewirtung( + $belegId, + !empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null, + !empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null ); // Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen - $konto = \Steuerkram\Database::fetchOne( - 'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?', - [(int)$data['kategorie_konto_id']] - ); + $konto = Account::find((int)$data['kategorie_konto_id']); $kontonummer = $konto['kontonummer'] ?? ''; $newAssetId = null; @@ -165,15 +159,12 @@ class BookingController extends BaseController // Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657 if (in_array($kontonummer, ['4650', '4651', '4652'], true)) { - $this->bucheBewirtungssplit($data, $kontonummer); + Booking::bucheBewirtungssplit($data, $kontonummer); } else { Booking::create($data); $eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0; - \Steuerkram\Database::execute( - 'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?', - [$eigenanteil, $belegId] - ); + Beleg::updateEigenanteil30($belegId, $eigenanteil); if ($eigenanteil === 1) { Booking::upsertEigenanteil30( @@ -187,10 +178,7 @@ class BookingController extends BaseController // Belegbetrag in Metadaten aktualisieren $totalBrutto = Booking::sumBruttoForBeleg($belegId); - \Steuerkram\Database::execute( - 'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?', - [$totalBrutto, $belegId] - ); + Beleg::updateBetragBrutto($belegId, $totalBrutto); // Bankbuchung verknüpfen wenn aus Bank-Kontext $bankTxId = (int)($data['bank_tx_id'] ?? 0); @@ -255,36 +243,6 @@ class BookingController extends BaseController ]); } - private function bucheBewirtungssplit(array $data, string $kontonummer): void - { - // Abzugskonto ermitteln - $abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' }; - $abzugskonto = \Steuerkram\Database::fetchOne( - 'SELECT id FROM accounts WHERE kontonummer = ?', - [$abzugskontonummer] - ); - if (!$abzugskonto) { - throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden."); - } - - $nettoGesamt = round((float)$data['betrag_netto'], 2); - $ustSatz = (float)($data['ust_satz'] ?? 0); - - $netto70 = round($nettoGesamt * 0.7, 2); - $netto30 = round($nettoGesamt - $netto70, 2); - - // Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig) - Booking::create(array_merge($data, [ - 'betrag_netto' => $netto70, - ])); - - // Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG) - Booking::create(array_merge($data, [ - 'kategorie_konto_id' => $abzugskonto['id'], - 'betrag_netto' => $netto30, - ])); - } - public function destroy(array $params): void { if (!Setting::isTestMode()) { @@ -299,8 +257,7 @@ class BookingController extends BaseController return; } - \Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]); - \Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]); + Booking::deleteHard($id); if (!empty($booking['beleg_id'])) { Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']); } diff --git a/src/Controllers/CustomerController.php b/src/Controllers/CustomerController.php index af0cb51..7b007db 100644 --- a/src/Controllers/CustomerController.php +++ b/src/Controllers/CustomerController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Customer; +use Steuerkram\Models\Invoice; use Steuerkram\View; class CustomerController extends BaseController @@ -69,10 +70,7 @@ class CustomerController extends BaseController $customer = Customer::find((int)$params['id']); if (!$customer) { http_response_code(404); return; } - $invoices = \Steuerkram\Database::fetchAll( - 'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT 20', - [$customer['id']] - ); + $invoices = Invoice::forCustomer((int)$customer['id']); $view = new View(); $view->render('layout/base.php', [ diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 6b16b5a..58aac2a 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -111,18 +111,12 @@ class InvoiceController extends BaseController $settings = Setting::get(); // Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung? - $stornorechnung = \Steuerkram\Database::fetchOne( - 'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1', - [(int)$params['id']] - ); + $stornorechnung = Invoice::findStorno((int)$params['id']); // Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden $originalrechnung = null; if (!empty($invoice['storno_of_id'])) { - $originalrechnung = \Steuerkram\Database::fetchOne( - 'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?', - [(int)$invoice['storno_of_id']] - ); + $originalrechnung = Invoice::findReference((int)$invoice['storno_of_id']); } // Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden) @@ -225,18 +219,12 @@ class InvoiceController extends BaseController { $id = (int)$params['id']; try { - \Steuerkram\Database::beginTransaction(); - $nr = Invoice::finalize($id); - $invoice = Invoice::find($id); - $belegNr = self::createBeleg($invoice); - \Steuerkram\Database::commit(); + [$nr, $belegNr] = Invoice::finalizeWithBeleg($id, fn(array $invoice): string => self::createBeleg($invoice)); View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen."); } catch (\PDOException $e) { - \Steuerkram\Database::rollBack(); error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage()); View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error'); } catch (\Exception $e) { - \Steuerkram\Database::rollBack(); View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } } @@ -275,17 +263,7 @@ class InvoiceController extends BaseController : null; (new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null); - if ($invoice['status'] === 'Finalisiert') { - \Steuerkram\Database::execute( - "UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?", - [$id] - ); - } else { - \Steuerkram\Database::execute( - 'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL', - [$id] - ); - } + Invoice::markSent($id, $invoice['status']); View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.'); } catch (\RuntimeException $e) { @@ -345,9 +323,11 @@ class InvoiceController extends BaseController } $absFilePath = Beleg::absolutePath(['storage_path' => $relPath]); file_put_contents($absFilePath, $pdf); - \Steuerkram\Database::execute( - 'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?', - [$relPath, ($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf', hash('sha256', $pdf), (int)$invoice['beleg_id']] + Beleg::updateStorage( + (int)$invoice['beleg_id'], + $relPath, + ($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf', + hash('sha256', $pdf) ); } @@ -571,9 +551,7 @@ class InvoiceController extends BaseController 'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'], ]); - \Steuerkram\Database::execute( - 'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id] - ); + Invoice::setBelegId($id, $belegId); return $belegNr; } @@ -613,10 +591,7 @@ class InvoiceController extends BaseController public function deleteLine(array $params): void { - \Steuerkram\Database::execute( - 'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?', - [(int)$params['line_id'], (int)$params['id']] - ); + Invoice::deleteLine((int)$params['id'], (int)$params['line_id']); View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.'); } diff --git a/src/Controllers/UstvaController.php b/src/Controllers/UstvaController.php index 28d0ade..7004540 100644 --- a/src/Controllers/UstvaController.php +++ b/src/Controllers/UstvaController.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Setting; -use Steuerkram\Database; +use Steuerkram\Models\UstvaSubmission; use Steuerkram\Services\UstvaCalculator; use Steuerkram\Services\ElsterXmlService; use Steuerkram\View; @@ -14,9 +14,7 @@ class UstvaController extends BaseController { public function index(array $params): void { - $submissions = Database::fetchAll( - 'SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC' - ); + $submissions = UstvaSubmission::all(); $years = []; for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y; @@ -45,9 +43,7 @@ class UstvaController extends BaseController $kz = $calc->calculate($year, $month); // Load saved submission if exists - $saved = Database::fetchOne( - 'SELECT * FROM ustva_submissions WHERE year=? AND month=?', [$year, $month] - ); + $saved = UstvaSubmission::find($year, $month); $settings = Setting::get(); $months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; @@ -78,22 +74,7 @@ class UstvaController extends BaseController $xmlSvc = new ElsterXmlService(); $xml = $xmlSvc->generate($year, $month, $kz); - // Upsert - $existing = Database::fetchOne( - 'SELECT id FROM ustva_submissions WHERE year=? AND month=?', [$year, $month] - ); - - if ($existing) { - Database::execute( - 'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?', - [$xml, json_encode($kz, JSON_UNESCAPED_UNICODE), $year, $month] - ); - } else { - Database::execute( - 'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)', - [$year, $month, $xml, json_encode($kz, JSON_UNESCAPED_UNICODE)] - ); - } + UstvaSubmission::upsert($year, $month, $xml, $kz); View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.'); } diff --git a/src/Models/BankTransaction.php b/src/Models/BankTransaction.php index 26feca9..a464b8a 100644 --- a/src/Models/BankTransaction.php +++ b/src/Models/BankTransaction.php @@ -261,6 +261,23 @@ class BankTransaction return ['new' => $new, 'dup' => $dup]; } + /** Importiert einen Batch Bank-Zeilen in einer Transaktion; bei Fehlern wird alles zurückgerollt. */ + public static function importBatch(int $sourceId, string $filename, array $rows): int + { + Database::beginTransaction(); + try { + $batchId = self::createBatch($sourceId, $filename, count($rows)); + foreach ($rows as $row) { + self::insertRow($batchId, $row); + } + Database::commit(); + return $batchId; + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; + } + } + public static function createBatch(int $sourceId, string $filename, int $rowCount): int { Database::execute( diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index 2cc2057..42c8297 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -250,6 +250,47 @@ class Beleg ); } + /** Setzt den Lieferanten ohne locked-Prüfung, z.B. beim automatischen Vorbelegen aus einer Bankbuchung. */ + public static function setSupplierId(int $id, int $supplierId): void + { + Database::execute( + 'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?', + [$supplierId, $id] + ); + } + + public static function updateBewirtung(int $id, ?string $bewirtungsgrund, ?string $teilnehmerliste): void + { + Database::execute( + 'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?', + [$bewirtungsgrund, $teilnehmerliste, $id] + ); + } + + public static function updateEigenanteil30(int $id, int $eigenanteil): void + { + Database::execute( + 'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?', + [$eigenanteil, $id] + ); + } + + public static function updateBetragBrutto(int $id, float $betrag): void + { + Database::execute( + 'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?', + [$betrag, $id] + ); + } + + public static function updateStorage(int $id, string $storagePath, string $filename, string $hash): void + { + Database::execute( + 'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?', + [$storagePath, $filename, $hash, $id] + ); + } + public static function delete(int $id): bool { $beleg = self::find($id); diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 687aa99..f04b1ad 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -608,4 +608,103 @@ class Booking [$year] ); } + + /** Kennzahlen für das Dashboard: offene Rechnungen und ungebuchte Belege. */ + public static function dashboardKennzahlen(): array + { + $offen = Database::fetchOne( + 'SELECT COUNT(*) as cnt, + COALESCE(SUM(i.betrag_brutto - COALESCE(( + SELECT SUM(bk.betrag_brutto) FROM bookings bk + WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\' + AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL + ), 0)), 0) as total + FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')' + ); + + $ungebucht = Database::fetchOne( + 'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)' + ); + + return [ + 'offene_rechnungen' => (int)($offen['cnt'] ?? 0), + 'offene_rechnungen_sum' => (float)($offen['total'] ?? 0), + 'ungebuchte_belege' => (int)($ungebucht['cnt'] ?? 0), + ]; + } + + /** Zuletzt angelegte Buchungen für das Dashboard. */ + public static function recent(int $limit = 10): array + { + return Database::fetchAll( + 'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name, + z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name, + bl.beleg_nr + FROM bookings bk + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN accounts z ON bk.zahlungskonto_id = z.id + JOIN belege bl ON bk.beleg_id = bl.id + ORDER BY bk.created_at DESC LIMIT ?', + [$limit] + ); + } + + /** Journal-Buchungen eines Kontos (als Kategorie- oder Zahlungskonto) in einem Geschäftsjahr. */ + public static function journalForAccount(int $accountId, int $year): array + { + return Database::fetchAll( + 'SELECT bk.*, + k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, + z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name, + bl.beleg_nr + FROM bookings bk + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN accounts z ON bk.zahlungskonto_id = z.id + LEFT JOIN belege bl ON bk.beleg_id = bl.id + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + WHERE fy.year = ? + AND ( + bk.kategorie_konto_id = ? + OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL + AND bk.kategorie_konto_id != ?) + ) + ORDER BY bk.buchungs_datum, bk.id', + [$year, $accountId, $accountId, $accountId] + ); + } + + /** Löscht eine Buchung samt Kindbuchungen unwiderruflich (nur Testmodus). */ + public static function deleteHard(int $id): void + { + Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]); + Database::execute('DELETE FROM bookings WHERE id = ?', [$id]); + } + + /** + * Bewirtungskonto-Split nach § 4 Abs. 5 EStG: 70 % abzugsfähig auf dem + * Bewirtungskonto, 30 % nicht abzugsfähig auf dem zugehörigen Abzugskonto. + */ + public static function bucheBewirtungssplit(array $data, string $kontonummer): void + { + $abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' }; + $abzugskonto = Account::findByKontonummer($abzugskontonummer); + if (!$abzugskonto) { + throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden."); + } + + $nettoGesamt = round((float)$data['betrag_netto'], 2); + $netto70 = round($nettoGesamt * 0.7, 2); + $netto30 = round($nettoGesamt - $netto70, 2); + + // Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig) + self::create(array_merge($data, [ + 'betrag_netto' => $netto70, + ])); + + // Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG) + self::create(array_merge($data, [ + 'kategorie_konto_id' => $abzugskonto['id'], + 'betrag_netto' => $netto30, + ])); + } } diff --git a/src/Models/FiscalYear.php b/src/Models/FiscalYear.php index 545f7b1..115d5bf 100644 --- a/src/Models/FiscalYear.php +++ b/src/Models/FiscalYear.php @@ -48,4 +48,10 @@ class FiscalYear ); return array_column($years, 'year'); } + + /** Angelegte Geschäftsjahre (fiscal_years-Tabelle), nicht aus Buchungen abgeleitet. */ + public static function registeredYears(): array + { + return Database::fetchAll('SELECT year FROM fiscal_years ORDER BY year DESC'); + } } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index d33ce91..6ba6888 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -162,6 +162,83 @@ class Invoice Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]); } + public static function setBelegId(int $id, int $belegId): void + { + Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]); + } + + /** Stornorechnung zu einer (Original-)Rechnung, falls vorhanden. */ + public static function findStorno(int $invoiceId): array|false + { + return Database::fetchOne( + 'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1', + [$invoiceId] + ); + } + + /** Kurzreferenz (Nr. + Datum) auf eine andere Rechnung, z.B. die Originalrechnung einer Stornorechnung. */ + public static function findReference(int $invoiceId): array|false + { + return Database::fetchOne( + 'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?', + [$invoiceId] + ); + } + + /** Setzt den Versandstatus; wiederholter Versand überschreibt sent_at nicht mehr. */ + public static function markSent(int $id, string $currentStatus): void + { + if ($currentStatus === 'Finalisiert') { + Database::execute( + "UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?", + [$id] + ); + } else { + Database::execute( + 'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL', + [$id] + ); + } + } + + public static function deleteLine(int $invoiceId, int $lineId): void + { + Database::execute( + 'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?', + [$lineId, $invoiceId] + ); + } + + public static function forCustomer(int $customerId, int $limit = 20): array + { + return Database::fetchAll( + 'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT ?', + [$customerId, $limit] + ); + } + + /** + * Finalisiert eine Rechnung und erzeugt in derselben Transaktion den + * zugehörigen Beleg über den übergebenen Callback (PDF-Erstellung ist + * Sache des Aufrufers, nicht der Modellschicht). + * + * @return array{0: string, 1: string} [Rechnungsnummer, Rückgabewert des Callbacks] + */ + public static function finalizeWithBeleg(int $id, callable $belegCallback): array + { + Database::beginTransaction(); + try { + $nr = self::finalize($id); + $invoice = self::find($id); + $belegNr = $belegCallback($invoice); + Database::commit(); + return [$nr, $belegNr]; + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; + } + } + /** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */ public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt']; diff --git a/src/Models/UstvaSubmission.php b/src/Models/UstvaSubmission.php new file mode 100644 index 0000000..6a202ed --- /dev/null +++ b/src/Models/UstvaSubmission.php @@ -0,0 +1,44 @@ +zahlungsId = (int)$this->findAccountId('1200'); + $this->kategorieId = (int)$this->findAccountId('8400'); + + if (!$this->zahlungsId || !$this->kategorieId) { + $this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + + $this->testDashboardKennzahlen(); + $this->testRecent(); + $this->testJournalForAccount(); + $this->testFiscalYearRegisteredYears(); + $this->testBewirtungssplit(); + $this->testDeleteHard(); + $this->testInvoiceQuerverweise(); + $this->testInvoiceFinalizeWithBeleg(); + $this->testBelegUpdates(); + $this->testUstvaSubmission(); + $this->testBankImportBatch(); + $this->testKeineDirektenDatenbankaufrufeInControllern(); + } + + private function testDashboardKennzahlen(): void + { + $vorher = Booking::dashboardKennzahlen(); + + $customerId = Customer::create([ + 'nachname' => 'Kennzahlen', + 'firma' => 'Kennzahlen GmbH', + 'strasse' => 'Teststraße 1', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 14, + ]); + $invoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung Dashboard-Kennzahlen', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [[ + 'bezeichnung' => 'Beratung', + 'menge' => 1, + 'einheit' => 'Std.', + 'einzelpreis' => 500.00, + 'ust_satz' => 19.0, + ]], + ]); + Invoice::finalize($invoiceId); + $invoice = Invoice::find($invoiceId); + + $belegId = $this->newBeleg('OFFEN', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']); + Invoice::setBelegId($invoiceId, $belegId); + Invoice::markSent($invoiceId, 'Finalisiert'); + + // Zweiter, völlig unabhängiger Beleg ohne jede Buchung. + $ungebuchterBelegId = $this->newBeleg('UNGEBUCHT', date('Y-m-d'), null, 42.00); + + $nachher = Booking::dashboardKennzahlen(); + + $this->assertEquals( + $vorher['offene_rechnungen'] + 1, + $nachher['offene_rechnungen'], + 'dashboardKennzahlen(): offene_rechnungen zählt die neue unbezahlte Rechnung' + ); + $this->assertEquals( + round($vorher['offene_rechnungen_sum'] + (float)$invoice['betrag_brutto'], 2), + round($nachher['offene_rechnungen_sum'], 2), + 'dashboardKennzahlen(): offene_rechnungen_sum enthält den vollen Rechnungsbetrag (keine Zahlung erfasst)' + ); + $this->assertEquals( + $vorher['ungebuchte_belege'] + 2, + $nachher['ungebuchte_belege'], + 'dashboardKennzahlen(): ungebuchte_belege zählt Rechnungs- und Einzelbeleg (beide ohne Buchung)' + ); + + // Für nachfolgende Tests weiterreichen. + $this->offenerBelegId = $belegId; + } + + private function testRecent(): void + { + // ust_satz 0, damit Booking::create() keine zusätzliche USt-Kindbuchung + // anlegt – sonst wäre nicht eindeutig, welche der beiden Zeilen jünger ist. + $bookingId = Booking::create([ + 'beleg_id' => $this->offenerBelegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $this->kategorieId, + 'zahlungskonto_id' => $this->zahlungsId, + 'betrag_netto' => 100.00, + 'ust_satz' => 0, + 'buchungstext' => 'Test recent() Buchung', + ]); + + $recentOne = Booking::recent(1); + $this->assertEquals(1, count($recentOne), 'recent(1) liefert genau einen Datensatz'); + $this->assertEquals($bookingId, (int)$recentOne[0]['id'], 'recent(1) liefert die zuletzt angelegte Buchung'); + $this->assertNotEmpty($recentOne[0]['kategorie_nr'] ?? null, 'recent() liefert die Kategorie-Kontonummer mit'); + $this->assertNotEmpty($recentOne[0]['beleg_nr'] ?? null, 'recent() liefert die Belegnummer mit'); + + $recentMany = Booking::recent(50); + $ids = array_column($recentMany, 'id'); + $this->assert(in_array($bookingId, $ids, true), 'recent(50) enthält die soeben angelegte Buchung'); + + $this->recentBookingId = $bookingId; + } + + private function testJournalForAccount(): void + { + $year = (int)date('Y'); + + $rows = Booking::journalForAccount($this->kategorieId, $year); + $ids = array_column($rows, 'id'); + $this->assert( + in_array($this->recentBookingId, $ids, true), + 'journalForAccount() findet die Buchung über das Kategorie-Konto' + ); + + $rowsZahlung = Booking::journalForAccount($this->zahlungsId, $year); + $idsZahlung = array_column($rowsZahlung, 'id'); + $this->assert( + in_array($this->recentBookingId, $idsZahlung, true), + 'journalForAccount() findet dieselbe Buchung auch über das Zahlungskonto' + ); + + $rowsAndererJahr = Booking::journalForAccount($this->kategorieId, $year - 50); + $idsAndererJahr = array_column($rowsAndererJahr, 'id'); + $this->assert( + !in_array($this->recentBookingId, $idsAndererJahr, true), + 'journalForAccount() filtert korrekt nach Geschäftsjahr' + ); + } + + private function testFiscalYearRegisteredYears(): void + { + $exotischesJahr = 2091; + FiscalYear::findOrCreateByYear($exotischesJahr); + + $jahre = array_column(FiscalYear::registeredYears(), 'year'); + $this->assert( + in_array($exotischesJahr, $jahre, true), + 'registeredYears() liefert ein frisch angelegtes Geschäftsjahr' + ); + } + + private function testBewirtungssplit(): void + { + $belegId = $this->newBeleg('BEWIRTUNG', date('Y-m-d'), null, 119.00); + + $data = [ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => (int)$this->findAccountId('4650'), + 'zahlungskonto_id' => $this->zahlungsId, + 'betrag_netto' => 100.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Testessen Bewirtung', + ]; + + Booking::bucheBewirtungssplit($data, '4650'); + + $hauptbuchungen = array_values(array_filter( + Booking::forBeleg($belegId), + fn(array $b): bool => $b['parent_booking_id'] === null + )); + $this->assertEquals(2, count($hauptbuchungen), 'bucheBewirtungssplit() legt zwei Hauptbuchungen an'); + + $konto4650Id = (int)$this->findAccountId('4650'); + $konto4654Id = (int)$this->findAccountId('4654'); + + $b70 = null; + $b30 = null; + foreach ($hauptbuchungen as $b) { + if ((int)$b['kategorie_konto_id'] === $konto4650Id) $b70 = $b; + if ((int)$b['kategorie_konto_id'] === $konto4654Id) $b30 = $b; + } + + $this->assertNotEmpty($b70, '70 %-Buchung liegt auf dem Bewirtungskonto 4650'); + $this->assertNotEmpty($b30, '30 %-Buchung liegt auf dem Abzugskonto 4654'); + $this->assertEquals(70.00, (float)($b70['betrag_netto'] ?? 0), '70 % Netto auf dem Bewirtungskonto'); + $this->assertEquals(30.00, (float)($b30['betrag_netto'] ?? 0), '30 % Netto auf dem Abzugskonto'); + } + + private function testDeleteHard(): void + { + $belegId = $this->newBeleg('DELETEHARD', date('Y-m-d'), null, 119.00); + + $bookingId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Einnahme', + 'kategorie_konto_id' => $this->kategorieId, + 'zahlungskonto_id' => $this->zahlungsId, + 'betrag_netto' => 100.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test deleteHard() Buchung', + ]); + + // 19 % USt erzeugt automatisch eine Kindbuchung (USt-Zeile). + $kinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]); + $this->assertGreaterThan(0, count($kinder), 'Voraussetzung: Hauptbuchung hat eine Kindbuchung'); + + Booking::deleteHard($bookingId); + + $this->assert(Booking::find($bookingId) === false, 'deleteHard() entfernt die Hauptbuchung'); + $verbleibendeKinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]); + $this->assertEquals(0, count($verbleibendeKinder), 'deleteHard() entfernt auch die Kindbuchung'); + } + + private function testInvoiceQuerverweise(): void + { + $customerId = Customer::create([ + 'nachname' => 'Querverweis', + 'firma' => 'Querverweis GmbH', + 'strasse' => 'Teststraße 2', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 14, + ]); + $invoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung Querverweis', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [[ + 'bezeichnung' => 'Beratung', + 'menge' => 1, + 'einheit' => 'Std.', + 'einzelpreis' => 200.00, + 'ust_satz' => 19.0, + ]], + ]); + Invoice::finalize($invoiceId); + $invoice = Invoice::find($invoiceId); + $belegId = $this->newBeleg('QUERVERWEIS', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']); + Invoice::setBelegId($invoiceId, $belegId); + + $stornoId = Invoice::createCancellation($invoiceId); + + $storno = Invoice::findStorno($invoiceId); + $this->assertEquals($stornoId, (int)($storno['id'] ?? 0), 'findStorno() liefert die Stornorechnung zur Originalrechnung'); + + $ref = Invoice::findReference($invoiceId); + $this->assertEquals($invoice['rechnungs_nr'], $ref['rechnungs_nr'] ?? null, 'findReference() liefert die Rechnungsnummer'); + $this->assertEquals($invoice['rechnungs_datum'], $ref['rechnungs_datum'] ?? null, 'findReference() liefert das Rechnungsdatum'); + + // deleteLine() + $lineInvoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung mit zwei Positionen', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [ + ['bezeichnung' => 'Position A', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 10.00, 'ust_satz' => 19.0], + ['bezeichnung' => 'Position B', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 20.00, 'ust_satz' => 19.0], + ], + ]); + $lines = Invoice::lines($lineInvoiceId); + $this->assertEquals(2, count($lines), 'Voraussetzung: Rechnung hat zwei Positionen'); + Invoice::deleteLine($lineInvoiceId, (int)$lines[0]['id']); + $this->assertEquals(1, count(Invoice::lines($lineInvoiceId)), 'deleteLine() entfernt genau eine Position'); + + // forCustomer() + $kundenRechnungen = array_column(Invoice::forCustomer($customerId), 'id'); + $this->assert(in_array($invoiceId, $kundenRechnungen, true), 'forCustomer() liefert die Rechnungen des Kunden'); + + // markSent() + $markInvoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung markSent', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 50.00, 'ust_satz' => 19.0]], + ]); + Invoice::finalize($markInvoiceId); + Invoice::markSent($markInvoiceId, 'Finalisiert'); + $markInvoice = Invoice::find($markInvoiceId); + $this->assertEquals('Gesendet', $markInvoice['status'], 'markSent() setzt den Status bei der ersten Sendung auf Gesendet'); + $this->assertNotEmpty($markInvoice['sent_at'], 'markSent() setzt sent_at'); + + $sentAtErsteSendung = $markInvoice['sent_at']; + Invoice::markSent($markInvoiceId, 'Gesendet'); + $markInvoiceErneut = Invoice::find($markInvoiceId); + $this->assertEquals('Gesendet', $markInvoiceErneut['status'], 'markSent() ändert den Status beim erneuten Versand nicht'); + $this->assertEquals($sentAtErsteSendung, $markInvoiceErneut['sent_at'], 'markSent() überschreibt sent_at beim erneuten Versand nicht'); + } + + private function testInvoiceFinalizeWithBeleg(): void + { + $customerId = Customer::create([ + 'nachname' => 'Finalisierung', + 'firma' => 'Finalisierung GmbH', + 'strasse' => 'Teststraße 3', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 14, + ]); + + $invoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung finalizeWithBeleg', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 80.00, 'ust_satz' => 19.0]], + ]); + + [$nr, $belegNr] = Invoice::finalizeWithBeleg($invoiceId, function (array $invoice): string { + return 'TESTBELEG-' . $invoice['id']; + }); + + $this->assertNotEmpty($nr, 'finalizeWithBeleg() liefert die vergebene Rechnungsnummer'); + $this->assertEquals('TESTBELEG-' . $invoiceId, $belegNr, 'finalizeWithBeleg() liefert den Rückgabewert des Beleg-Callbacks'); + $this->assertEquals('Finalisiert', Invoice::find($invoiceId)['status'], 'Rechnung ist nach finalizeWithBeleg() finalisiert'); + } + + private function testBelegUpdates(): void + { + $belegId = $this->newBeleg('UPDATES', date('Y-m-d'), null, 50.00); + + Beleg::updateBewirtung($belegId, 'Geschäftsessen', 'Person A, Person B'); + $beleg = Beleg::find($belegId); + $this->assertEquals('Geschäftsessen', $beleg['bewirtungsgrund'], 'updateBewirtung() setzt bewirtungsgrund'); + $this->assertEquals('Person A, Person B', $beleg['teilnehmerliste'], 'updateBewirtung() setzt teilnehmerliste'); + + Beleg::updateEigenanteil30($belegId, 1); + $this->assertEquals(1, (int)Beleg::find($belegId)['eigenanteil_30'], 'updateEigenanteil30() setzt das Flag'); + + // Beleg::find() überschreibt betrag_brutto mit einer SUM-Subquery über + // gebuchte Zahlungen (0 ohne Buchungen) – die rohe Spalte daher direkt prüfen. + Beleg::updateBetragBrutto($belegId, 123.45); + $rohwert = Database::fetchOne('SELECT betrag_brutto FROM belege WHERE id = ?', [$belegId]); + $this->assertEquals(123.45, (float)($rohwert['betrag_brutto'] ?? 0), 'updateBetragBrutto() aktualisiert den Betrag'); + + $hash = hash('sha256', 'test-inhalt-' . $belegId); + Beleg::updateStorage($belegId, '2026/TEST/original.pdf', 'Testdatei.pdf', $hash); + $belegNachUpdate = Beleg::find($belegId); + $this->assertEquals('2026/TEST/original.pdf', $belegNachUpdate['storage_path'], 'updateStorage() aktualisiert storage_path'); + $this->assertEquals('Testdatei.pdf', $belegNachUpdate['filename'], 'updateStorage() aktualisiert filename'); + $this->assertEquals($hash, $belegNachUpdate['file_hash'], 'updateStorage() aktualisiert file_hash'); + + $supplierId = Supplier::findOrCreate('Testlieferant Modellabfragen'); + Beleg::setSupplierId($belegId, $supplierId); + $this->assertEquals($supplierId, (int)Beleg::find($belegId)['supplier_id'], 'setSupplierId() setzt den Lieferanten ohne locked-Prüfung'); + } + + private function testUstvaSubmission(): void + { + $year = 2077; + $month = 6; + + $this->assert(UstvaSubmission::find($year, $month) === false, 'Vor dem Speichern existiert noch keine Übermittlung'); + + UstvaSubmission::upsert($year, $month, 'erster-stand', ['81' => ['wert' => 100]]); + $ersterStand = UstvaSubmission::find($year, $month); + $this->assertEquals('erster-stand', $ersterStand['xml_content'], 'upsert() legt beim ersten Aufruf einen Datensatz an'); + + UstvaSubmission::upsert($year, $month, 'zweiter-stand', ['81' => ['wert' => 200]]); + $zweiterStand = UstvaSubmission::find($year, $month); + $this->assertEquals('zweiter-stand', $zweiterStand['xml_content'], 'upsert() überschreibt beim zweiten Aufruf den Inhalt'); + $this->assertEquals((int)$ersterStand['id'], (int)$zweiterStand['id'], 'upsert() legt beim zweiten Aufruf keinen zweiten Datensatz an'); + + $alleIds = array_column(UstvaSubmission::all(), 'id'); + $this->assert(in_array((int)$zweiterStand['id'], $alleIds, true), 'all() enthält die gespeicherte Übermittlung'); + } + + private function testBankImportBatch(): void + { + $sourceId = BankSource::create([ + 'type' => 'csv', + 'bezeichnung' => 'Test Modellabfragen CSV', + 'kuerzel' => 'TMA', + 'farbe' => '#123456', + 'skip_rows' => 0, + 'has_header_row' => 1, + 'delimiter' => ';', + 'enclosure' => '"', + 'col_buchungstag' => 0, + 'col_wertstellung' => 1, + 'col_vorgang' => 2, + 'col_buchungstext' => 3, + 'col_wert' => 4, + ]); + + $rows = [ + ['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 1', 'wert' => '10.00'], + ['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 2', 'wert' => '20.00'], + ]; + + $batchId = BankTransaction::importBatch($sourceId, 'modellabfragen-test.csv', $rows); + $this->assertGreaterThan(0, $batchId, 'importBatch() legt einen Import-Batch an'); + + $importiert = Database::fetchAll('SELECT * FROM bank_transactions WHERE batch_id = ? ORDER BY id', [$batchId]); + $this->assertEquals(2, count($importiert), 'importBatch() legt beide Zeilen im selben Batch an'); + $this->assertEquals('Modellabfragen Testzeile 1', $importiert[0]['buchungstext'] ?? null, 'Erste Zeile wird unverändert übernommen'); + $this->assertEquals('20.00', $importiert[1]['wert'] ?? null, 'Zweite Zeile wird unverändert übernommen'); + } + + /** + * Regressionsschutz: kein Controller darf wieder direkt auf Database:: zugreifen. + * + * Ausnahme AssetController.php: dort verbleiben vier Aufrufe außerhalb des + * AfA-Umfangs von Fix #155 (Anlage-Verknüpfung einer Buchung, vorheriges + * AfA-Konto, harte Löschung im Testmodus) – laut Issue #156 nicht Teil + * dieses Tickets. + */ + private function testKeineDirektenDatenbankaufrufeInControllern(): void + { + $verzeichnis = dirname(__DIR__) . '/src/Controllers'; + $ausnahmen = ['AssetController.php']; + + $treffer = []; + foreach (glob($verzeichnis . '/*.php') ?: [] as $datei) { + if (in_array(basename($datei), $ausnahmen, true)) continue; + $inhalt = file_get_contents($datei); + if ($inhalt !== false && str_contains($inhalt, 'Database::')) { + $treffer[] = basename($datei); + } + } + + $this->assert( + empty($treffer), + empty($treffer) + ? 'Kein Controller (außer den dokumentierten Ausnahmen) ruft Database:: direkt auf' + : 'Unerwartete Database::-Aufrufe in: ' . implode(', ', $treffer) + ); + } + + private function newBeleg(string $suffix, string $belegDatum, ?string $rechnungsNr, float $betragBrutto): int + { + $this->belegSeq++; + return Beleg::create([ + 'beleg_nr' => Beleg::generateBelegNr(), + 'filename' => "modellabfragen-{$suffix}-{$this->belegSeq}.pdf", + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $suffix . $this->belegSeq), + 'beleg_datum' => $belegDatum, + 'rechnungs_nr' => $rechnungsNr, + 'betrag_brutto' => $betragBrutto, + 'belegtyp' => $rechnungsNr !== null ? 'Ausgangsrechnung' : 'Eingangsrechnung', + ]); + } +} From d0a8a6dfb7b2ff42410ea2aaeea570923bf4a16c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 06:03:55 +0200 Subject: [PATCH 2/3] Fix #157: belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt --- Changes.md | 11 + public/static/js/beleg-anlage.js | 117 ++ public/static/js/beleg-crop.js | 152 +++ public/static/js/beleg-form.js | 251 +++++ public/static/js/beleg-ki.js | 162 +++ public/static/js/beleg-ui.js | 146 +++ src/Views/belege/_anlage_modal.php | 59 + src/Views/belege/_buchungsform.php | 235 ++++ src/Views/belege/_crop_modal.php | 25 + src/Views/belege/_infobar.php | 169 +++ src/Views/belege/_vorschau.php | 212 ++++ src/Views/belege/detail.php | 1545 +------------------------- tests/14_UstBruttoAnzeigeTest.php | 21 +- tests/61_BetragModusToggleTest.php | 23 +- tests/86_BelegDetailStrukturTest.php | 98 ++ 15 files changed, 1681 insertions(+), 1545 deletions(-) create mode 100644 public/static/js/beleg-anlage.js create mode 100644 public/static/js/beleg-crop.js create mode 100644 public/static/js/beleg-form.js create mode 100644 public/static/js/beleg-ki.js create mode 100644 public/static/js/beleg-ui.js create mode 100644 src/Views/belege/_anlage_modal.php create mode 100644 src/Views/belege/_buchungsform.php create mode 100644 src/Views/belege/_crop_modal.php create mode 100644 src/Views/belege/_infobar.php create mode 100644 src/Views/belege/_vorschau.php create mode 100644 tests/86_BelegDetailStrukturTest.php diff --git a/Changes.md b/Changes.md index f377105..70b67bd 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,17 @@ --- +## 2026-08-11 – belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt (#157) + +- `src/Views/belege/detail.php` war mit 1.548 Zeilen die mit Abstand größte Datei der Anwendung, davon 827 Zeilen JavaScript in neun ` - - - -
Dieser Beleg ist gesperrt (GoBD). Keine weiteren Buchungen möglich.
- - - - - - - - - [ - 'id' => (int)$a['id'], - 'lfd_nr' => (int)$a['lfd_nr'], - 'bezeichnung' => $a['bezeichnung'], - 'anschaffungsdatum' => $a['anschaffungsdatum'], - 'anschaffungskosten' => (float)$a['anschaffungskosten'], - 'account_id' => $a['account_id'] !== null ? (int)$a['account_id'] : null, -], $assets ?? []), JSON_HEX_TAG); -?> - - - - - - - - + + + + - - - - + - - + + + + - - - - - + + + diff --git a/tests/14_UstBruttoAnzeigeTest.php b/tests/14_UstBruttoAnzeigeTest.php index d793b5b..83045e8 100644 --- a/tests/14_UstBruttoAnzeigeTest.php +++ b/tests/14_UstBruttoAnzeigeTest.php @@ -5,8 +5,9 @@ declare(strict_types=1); /** * Test 14: USt- und Brutto-Anzeige im Buchungsformular * - * Prüft, dass die notwendigen HTML-Elemente und die JS-Funktion - * updateNettoAnzeige() im Beleg-Detailview vorhanden sind. + * Prüft, dass die notwendigen HTML-Elemente (Teil-View _buchungsform.php) + * und die JS-Funktion updateNettoAnzeige() (static/js/beleg-form.js) + * vorhanden sind. */ class UstBruttoAnzeigeTest extends TestCase { @@ -14,12 +15,20 @@ class UstBruttoAnzeigeTest extends TestCase public function run(): void { - $viewFile = __DIR__ . '/../src/Views/belege/detail.php'; - $content = file_get_contents($viewFile); + $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php'; + $jsFile = __DIR__ . '/../public/static/js/beleg-form.js'; + $viewContent = file_get_contents($viewFile); + $jsContent = file_get_contents($jsFile); + $content = $viewContent . "\n" . $jsContent; $this->assert( - $content !== false, - 'View-Datei belege/detail.php ist lesbar' + $viewContent !== false, + 'Teil-View belege/_buchungsform.php ist lesbar' + ); + + $this->assert( + $jsContent !== false, + 'JS-Datei static/js/beleg-form.js ist lesbar' ); $this->assert( diff --git a/tests/61_BetragModusToggleTest.php b/tests/61_BetragModusToggleTest.php index b6ee142..9023234 100644 --- a/tests/61_BetragModusToggleTest.php +++ b/tests/61_BetragModusToggleTest.php @@ -5,9 +5,10 @@ declare(strict_types=1); /** * Test 61: Netto/Brutto-Umschalter im Buchungsformular * - * Prüft, dass im Beleg-Detailview die beiden Umschalt-Buttons für - * Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik - * (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) existiert. + * Prüft, dass in der Teil-View _buchungsform.php die beiden Umschalt-Buttons + * für Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik + * (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) in + * static/js/beleg-form.js existiert. */ class BetragModusToggleTest extends TestCase { @@ -15,12 +16,20 @@ class BetragModusToggleTest extends TestCase public function run(): void { - $viewFile = __DIR__ . '/../src/Views/belege/detail.php'; - $content = file_get_contents($viewFile); + $viewFile = __DIR__ . '/../src/Views/belege/_buchungsform.php'; + $jsFile = __DIR__ . '/../public/static/js/beleg-form.js'; + $viewContent = file_get_contents($viewFile); + $jsContent = file_get_contents($jsFile); + $content = $viewContent . "\n" . $jsContent; $this->assert( - $content !== false, - 'View-Datei belege/detail.php ist lesbar' + $viewContent !== false, + 'Teil-View belege/_buchungsform.php ist lesbar' + ); + + $this->assert( + $jsContent !== false, + 'JS-Datei static/js/beleg-form.js ist lesbar' ); $this->assert( diff --git a/tests/86_BelegDetailStrukturTest.php b/tests/86_BelegDetailStrukturTest.php new file mode 100644 index 0000000..dad3f89 --- /dev/null +++ b/tests/86_BelegDetailStrukturTest.php @@ -0,0 +1,98 @@ +-Blöcken) die mit Abstand größte View der Anwendung. Dieser Test + * hält die im Zuge der Aufteilung entstandene Struktur fest: detail.php ist + * nur noch ein Gerüst aus Includes und