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