diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f7bfd21 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# Steuerkram – Entwicklungsregeln + +## Tech Stack +PHP 8.3+, MySQL, PSR-4 (Namespace `Steuerkram\`), Composer, kein Framework. +MVC: `src/Controllers/`, `src/Models/`, `src/Services/`, `src/Views/`. + +## Coding-Regeln +- Kein `var_dump`, kein `die()` in Produktionscode. +- Neue Libraries nur nach expliziter Rückfrage einbinden. +- Bestehenden Code-Stil beibehalten (declare strict_types, 4 Spaces). +- SQL ausschließlich über `Database::fetchAll/fetchOne/execute()` mit Prepared Statements. + +## Tests +**Regel: Jedes neue Feature bekommt einen eigenen Test.** + +Tests liegen in `tests/` und folgen dem Namensschema `NN_FunktionsnameTest.php`. +Jede Testklasse erweitert `TestCase` und implementiert `run()`. + +```bash +php tests/run.php # alle Tests ausführen +./scripts/run-tests.sh # Tests + automatische Issue-Erstellung bei Fehlern +./scripts/run-tests.sh 42 # Tests; bei Fehlern Kommentar in Issue #42 +``` + +### Neuen Test erstellen +1. Datei `tests/NN_MeinFeatureTest.php` anlegen (NN = nächste freie Nummer) +2. Klasse `MeinFeatureTest extends TestCase` +3. `protected string $testName = 'Test NN: Beschreibung';` +4. Methode `public function run(): void` mit Assertions implementieren +5. Transaktions-Isolation ist automatisch (setUp/tearDown in TestCase) + +### Assertion-Methoden +```php +$this->assert(bool, 'Beschreibung'); +$this->assertEquals($erwartet, $ist, 'Beschreibung'); +$this->assertGreaterThan($min, $wert, 'Beschreibung'); +$this->assertNotEmpty($wert, 'Beschreibung'); +``` + +## Commits +``` +Fix #NR: Kurze Beschreibung der Änderung +``` +Keine Co-Authored-By-Zeile. Keine langen Commit-Messages. diff --git a/docker-compose.yml b/docker-compose.yml index 9c9459b..9bb0261 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - ./migrations:/var/www/html/migrations - uploads:/var/www/html/public/static/uploads - ./data:/var/www/html/data + - ./tests:/var/www/html/tests + - ./scripts:/var/www/html/scripts environment: DB_HOST: db DB_NAME: steuerkram diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..3417fed --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +# ============================================================ +# Steuerkram Test-Runner mit automatischer Issue-Erstellung +# +# Verwendung: +# ./scripts/run-tests.sh [ISSUE_NUMMER] +# +# Parameter: +# ISSUE_NUMMER (optional) Nummer des aktuell bearbeiteten Issues, +# auf das bei neu erstellten Bug-Issues verwiesen wird. +# +# Umgebungsvariablen: +# CODEBERG_TOKEN Codeberg-API-Token (für Issue-Erstellung) +# +# Exit-Code: 0 = alle Tests OK, 1 = Fehler gefunden +# ============================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CURRENT_ISSUE="${1:-}" +REPO="fds2610/Steuerkram" +API_BASE="https://codeberg.org/api/v1" + +cd "$PROJECT_DIR" + +# ── Tests ausführen ────────────────────────────────────────── +echo "" +echo "Führe Tests aus..." +JSON_OUTPUT=$(php tests/run.php --json 2>&1) || true +EXIT_CODE=$? + +# Auch die lesbare Ausgabe zeigen +php tests/run.php || true + +# ── Ergebnis auswerten ─────────────────────────────────────── +FAILED=$(echo "$JSON_OUTPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('failed',0))" 2>/dev/null || echo "0") +FAILURES_JSON=$(echo "$JSON_OUTPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get('failures',[])))" 2>/dev/null || echo "[]") + +if [ "$FAILED" = "0" ] || [ "$FAILED" = "" ]; then + echo "" + echo "✓ Alle Tests bestanden. Commit kann fortgesetzt werden." + exit 0 +fi + +echo "" +echo "✗ ${FAILED} Test(s) fehlgeschlagen." + +# ── Issues erstellen (nur wenn Token vorhanden) ────────────── +if [ -z "${CODEBERG_TOKEN:-}" ]; then + echo "" + echo "Hinweis: CODEBERG_TOKEN nicht gesetzt – keine automatischen Issues erstellt." + echo "Fehlgeschlagene Tests:" + echo "$FAILURES_JSON" | python3 -c "import json,sys; [print(' •', f) for f in json.load(sys.stdin)]" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "Erstelle automatische Bug-Issues auf Codeberg..." + +CREATED_ISSUES=() + +# Jede Fehlermeldung als eigenes Issue anlegen +while IFS= read -r failure; do + [ -z "$failure" ] && continue + + # Titel kürzen (max. 100 Zeichen) + TITLE="[BUG] Test fehlgeschlagen: ${failure:0:80}" + + # Body zusammenbauen + BODY="## Automatisch erkannter Testfehler\n\n" + BODY+="**Fehlermeldung:** \`${failure}\`\n\n" + BODY+="**Erkannt am:** $(date '+%Y-%m-%d %H:%M:%S')\n\n" + BODY+="**Commit:** $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')\n\n" + + if [ -n "$CURRENT_ISSUE" ]; then + BODY+="Dieser Fehler wurde beim Bearbeiten von Issue #${CURRENT_ISSUE} entdeckt.\n\n" + fi + + BODY+="### Schritte zur Reproduktion\n" + BODY+="1. \`php tests/run.php\` ausführen\n" + BODY+="2. Fehler tritt im oben genannten Test auf\n\n" + BODY+="_Dieser Issue wurde automatisch durch den Test-Runner erstellt._\n" + BODY+="_Das Label 'automate' wurde bewusst NICHT gesetzt (Human-in-the-loop)._" + + RESPONSE=$(curl -s -X POST \ + -H "Authorization: token $CODEBERG_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 -c "import json; print(json.dumps({'title': '${TITLE}', 'body': '${BODY}', 'labels': []}))")" \ + "${API_BASE}/repos/${REPO}/issues") + + ISSUE_NR=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('number','?'))" 2>/dev/null || echo "?") + CREATED_ISSUES+=("#${ISSUE_NR}") + echo " → Issue #${ISSUE_NR} erstellt: ${failure:0:60}..." + +done < <(echo "$FAILURES_JSON" | python3 -c "import json,sys; [print(f) for f in json.load(sys.stdin)]" 2>/dev/null) + +# ── Kommentar im aktuellen Issue hinterlassen ───────────────── +if [ -n "$CURRENT_ISSUE" ] && [ ${#CREATED_ISSUES[@]} -gt 0 ]; then + ISSUE_LINKS=$(printf '%s, ' "${CREATED_ISSUES[@]}") + ISSUE_LINKS="${ISSUE_LINKS%, }" + + COMMENT="### Automatische Testläufe\n\n" + COMMENT+="Beim Bearbeiten dieses Issues wurden **${FAILED} Test(s)** fehlgeschlagen.\n\n" + COMMENT+="Folgende Bug-Issues wurden automatisch erstellt (ohne 'automate'-Label – bitte manuell prüfen):\n\n" + for issue in "${CREATED_ISSUES[@]}"; do + COMMENT+="- ${issue}\n" + done + COMMENT+="\n_Commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')_" + + curl -s -X POST \ + -H "Authorization: token $CODEBERG_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(python3 -c "import json; print(json.dumps({'body': '${COMMENT}'}))")" \ + "${API_BASE}/repos/${REPO}/issues/${CURRENT_ISSUE}/comments" > /dev/null + + echo "" + echo "Kommentar mit Links zu Bug-Issues in Issue #${CURRENT_ISSUE} hinterlassen." +fi + +exit 1 diff --git a/tests/01_BelegUploadTest.php b/tests/01_BelegUploadTest.php new file mode 100644 index 0000000..8a88d53 --- /dev/null +++ b/tests/01_BelegUploadTest.php @@ -0,0 +1,57 @@ + $belegNr, + 'filename' => 'test-rechnung.pdf', + 'storage_path' => 'static/uploads/test/test-rechnung.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => $datum, + 'eingangs_datum' => $datum, + 'rechnungs_nr' => 'RE-TEST-001', + 'betrag_brutto' => 119.00, + 'belegtyp' => 'Eingangsrechnung', + 'notes' => 'Automatisch erstellt (Test)', + ]); + + $this->assertGreaterThan(0, $id, 'Beleg wurde in der DB angelegt (ID > 0)'); + + // Beleg via find() abrufbar + $beleg = \Steuerkram\Models\Beleg::find($id); + $this->assertNotEmpty($beleg, 'Beleg::find() liefert einen Datensatz'); + $this->assertEquals($belegNr, $beleg['beleg_nr'] ?? null, 'Belegnummer stimmt überein'); + $this->assertEquals('Eingangsrechnung', $beleg['belegtyp'] ?? null, 'Belegtyp ist Eingangsrechnung'); + + // Beleg erscheint in Beleg::all() (ohne Jahr-Filter, um sicher zu finden) + $result = \Steuerkram\Models\Beleg::all([], 1, 999); + $found = false; + foreach ($result['rows'] as $row) { + if ((int)$row['id'] === $id) { + $found = true; + break; + } + } + $this->assert($found, 'Beleg erscheint in Beleg::all() (Belegübersicht)'); + $this->assertGreaterThan(0, $result['total'], 'Belegübersicht enthält mindestens 1 Eintrag'); + + // Betrag korrekt gespeichert + $this->assertEquals(119.00, (float)($beleg['betrag_brutto'] ?? 0), 'Betrag brutto korrekt gespeichert'); + } +} diff --git a/tests/02_BelegBuchungTest.php b/tests/02_BelegBuchungTest.php new file mode 100644 index 0000000..9575330 --- /dev/null +++ b/tests/02_BelegBuchungTest.php @@ -0,0 +1,110 @@ +findAccountId('4920'); // Bürobedarf (Aufwand) + $bankId = $this->findAccountId('1200'); // Bank (Aktiv) + + if (!$aufwandId || !$bankId) { + // Fallback: irgendein Aufwand- und Aktiv-Konto suchen + $aufwandRow = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Aufwand' AND aktiv = 1 LIMIT 1" + ); + $bankRow = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 LIMIT 1" + ); + $aufwandId = $aufwandRow ? (int)$aufwandRow['id'] : null; + $bankId = $bankRow ? (int)$bankRow['id'] : null; + } + + $this->assertNotEmpty($aufwandId, 'Aufwandskonto gefunden'); + $this->assertNotEmpty($bankId, 'Aktiv-/Bankkonto gefunden'); + + if (!$aufwandId || !$bankId) { + return; // Voraussetzungen nicht erfüllt, weitere Asserts wären sinnlos + } + + // Beleg anlegen + $belegNr = 'TEST-' . uniqid(); + $datum = date('Y-m-d'); + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'eingangsrechnung-test.pdf', + 'storage_path' => 'static/uploads/test/eingangsrechnung-test.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr . 'buch'), + 'beleg_datum' => $datum, + 'eingangs_datum' => $datum, + 'rechnungs_nr' => 'RE-BUCH-001', + 'betrag_brutto' => 59.50, + 'belegtyp' => 'Eingangsrechnung', + ]); + $this->assertGreaterThan(0, $belegId, 'Beleg für Buchungstest angelegt'); + + // Buchung erstellen + $buchungId = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'soll_konto_id' => $aufwandId, + 'haben_konto_id' => $bankId, + 'betrag_netto' => 50.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Testbuchung Bürobedarf', + ]); + $this->assertGreaterThan(0, $buchungId, 'Buchung wurde angelegt (ID > 0)'); + + // Buchung via Booking::find() abrufbar + $buchung = \Steuerkram\Models\Booking::find($buchungId); + $this->assertNotEmpty($buchung, 'Booking::find() liefert die Buchung'); + $this->assertEquals(50.00, (float)($buchung['betrag_netto'] ?? 0), 'Nettobetrag korrekt'); + $this->assertEquals(19.0, (float)($buchung['ust_satz'] ?? 0), 'USt-Satz korrekt'); + + // Buchung via Booking::forBeleg() abrufbar + $buchungen = \Steuerkram\Models\Booking::forBeleg($belegId); + $this->assertGreaterThan(0, count($buchungen), 'Booking::forBeleg() liefert Buchungen'); + + $foundInBeleg = false; + foreach ($buchungen as $bk) { + if ((int)$bk['id'] === $buchungId) { + $foundInBeleg = true; + break; + } + } + $this->assert($foundInBeleg, 'Buchung erscheint in Booking::forBeleg()'); + + // Buchung im Journal + $year = (int)date('Y'); + $month = (int)date('n'); + $journal = \Steuerkram\Models\Booking::journal(['year' => $year, 'month' => $month], 1, 999); + $foundInJournal = false; + foreach ($journal['rows'] as $row) { + if ((int)$row['id'] === $buchungId) { + $foundInJournal = true; + // Vollständigkeit prüfen + $this->assertNotEmpty($row['buchungs_nr'], 'Buchungsnummer im Journal vorhanden'); + $this->assertNotEmpty($row['soll_nr'], 'Soll-Kontonummer im Journal vorhanden'); + $this->assertNotEmpty($row['haben_nr'], 'Haben-Kontonummer im Journal vorhanden'); + $this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden'); + break; + } + } + $this->assert($foundInJournal, 'Buchung erscheint vollständig im Buchungsjournal'); + + // Beleg ist jetzt als gebucht markiert + $belegNeu = \Steuerkram\Models\Beleg::find($belegId); + $this->assertEquals(1, (int)($belegNeu['booked'] ?? 0), 'Beleg ist als gebucht markiert'); + } +} diff --git a/tests/03_AusgangsrechnungTest.php b/tests/03_AusgangsrechnungTest.php new file mode 100644 index 0000000..75ad0c6 --- /dev/null +++ b/tests/03_AusgangsrechnungTest.php @@ -0,0 +1,83 @@ + 'Testmann', + 'vorname' => 'Karl', + 'firma' => 'Test GmbH', + 'strasse' => 'Teststraße 1', + 'plz' => '12345', + 'ort' => 'Teststadt', + 'land' => 'DE', + 'payment_days' => 14, + ]); + $this->assertGreaterThan(0, $customerId, 'Testkunde wurde angelegt'); + + // Ausgangsrechnung erstellen + $datum = date('Y-m-d'); + $invoiceId = \Steuerkram\Models\Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung Webentwicklung', + 'rechnungs_datum' => $datum, + 'leistungszeitraum_von' => date('Y-m-01'), + 'leistungszeitraum_bis' => date('Y-m-t'), + 'notes' => 'Automatisch erstellt (Test)', + 'lines' => [ + [ + 'bezeichnung' => 'Webentwicklung', + 'menge' => 10, + 'einheit' => 'Std.', + 'einzelpreis' => 100.00, + 'ust_satz' => 19.0, + ], + ], + ]); + $this->assertGreaterThan(0, $invoiceId, 'Rechnung wurde in der DB angelegt (ID > 0)'); + + // Rechnung via Invoice::find() abrufbar + $invoice = \Steuerkram\Models\Invoice::find($invoiceId); + $this->assertNotEmpty($invoice, 'Invoice::find() liefert einen Datensatz'); + $this->assertEquals('Testrechnung Webentwicklung', $invoice['betreff'] ?? null, 'Betreff stimmt überein'); + $this->assertEquals('Entwurf', $invoice['status'] ?? null, 'Status ist Entwurf'); + $this->assertEquals($customerId, (int)($invoice['customer_id'] ?? 0), 'Kunde korrekt zugeordnet'); + + // Betragsberechnung prüfen + $erwNetto = 1000.00; // 10 Std. × 100 € + $erwUst = 190.00; // 19% + $erwBrutto = 1190.00; + $this->assertEquals($erwNetto, (float)($invoice['betrag_netto'] ?? 0), 'Nettobetrag korrekt berechnet'); + $this->assertEquals($erwUst, (float)($invoice['betrag_ust'] ?? 0), 'USt-Betrag korrekt berechnet'); + $this->assertEquals($erwBrutto, (float)($invoice['betrag_brutto'] ?? 0), 'Bruttobetrag korrekt berechnet'); + + // Positionen vorhanden + $lines = \Steuerkram\Models\Invoice::lines($invoiceId); + $this->assertEquals(1, count($lines), 'Rechnung hat genau 1 Position'); + $this->assertEquals('Webentwicklung', $lines[0]['bezeichnung'] ?? null, 'Positionsbezeichnung korrekt'); + + // Rechnung in Invoice::all() auffindbar + $result = \Steuerkram\Models\Invoice::all([], 1, 999); + $found = false; + foreach ($result['rows'] as $row) { + if ((int)$row['id'] === $invoiceId) { + $found = true; + break; + } + } + $this->assert($found, 'Rechnung erscheint in Invoice::all() (Rechnungsübersicht)'); + $this->assertGreaterThan(0, $result['total'], 'Rechnungsübersicht enthält mindestens 1 Eintrag'); + } +} diff --git a/tests/04_InvoiceBuchungTest.php b/tests/04_InvoiceBuchungTest.php new file mode 100644 index 0000000..259b58d --- /dev/null +++ b/tests/04_InvoiceBuchungTest.php @@ -0,0 +1,134 @@ +findAccountId('1400'); // Debitorenkonto + $erloesId = $this->findAccountId('8000'); // Erlöse 19 % + + if (!$debitorId) { + $this->assert(false, 'Debitorenkonto 1400 nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + if (!$erloesId) { + $this->assert(false, 'Erlöskonto 8000 nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + + $this->assert(true, 'Benötigte Konten (1400, 8000) vorhanden'); + + // Testkunden anlegen + $customerId = \Steuerkram\Models\Customer::create([ + 'nachname' => 'Buchungstest', + 'firma' => 'Buchungs GmbH', + 'strasse' => 'Hauptstraße 1', + 'plz' => '10115', + 'ort' => 'Berlin', + 'payment_days' => 30, + ]); + + // Rechnung anlegen und finalisieren + $datum = date('Y-m-d'); + $invoiceId = \Steuerkram\Models\Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Testrechnung für Buchungstest', + 'rechnungs_datum' => $datum, + 'leistungszeitraum_von' => date('Y-m-01'), + 'leistungszeitraum_bis' => date('Y-m-t'), + 'lines' => [ + [ + 'bezeichnung' => 'Beratungsleistung', + 'menge' => 5, + 'einheit' => 'Std.', + 'einzelpreis' => 200.00, + 'ust_satz' => 19.0, + ], + ], + ]); + + $nr = \Steuerkram\Models\Invoice::finalize($invoiceId); + $this->assertNotEmpty($nr, 'Rechnung wurde finalisiert (Rechnungsnummer erhalten)'); + + $invoice = \Steuerkram\Models\Invoice::find($invoiceId); + $this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Rechnungsstatus ist Gesendet'); + + // Buchungen erzeugen (wie InvoiceController::createBelegAndBookings) + $belegNr = \Steuerkram\Models\Beleg::generateBelegNr(); + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => $nr . '.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $nr), + 'beleg_datum' => $invoice['rechnungs_datum'], + 'eingangs_datum' => $invoice['rechnungs_datum'], + 'rechnungs_nr' => $nr, + 'betrag_brutto' => $invoice['betrag_brutto'], + 'belegtyp' => 'Ausgangsrechnung', + ]); + + $lines = \Steuerkram\Models\Invoice::lines($invoiceId); + $gruppenNetto = []; + foreach ($lines as $line) { + $satz = (float)$line['ust_satz']; + $netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2); + $gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto; + } + + $buchungIds = []; + foreach ($gruppenNetto as $satz => $netto) { + $buchungIds[] = \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $invoice['rechnungs_datum'], + 'soll_konto_id' => $debitorId, + 'haben_konto_id' => $erloesId, + 'betrag_netto' => round($netto, 2), + 'ust_satz' => $satz, + 'buchungstext' => 'Ausgangsrechnung ' . $nr, + ]); + } + + $this->assertGreaterThan(0, count($buchungIds), 'Mindestens eine Buchung wurde erzeugt'); + + // Im Journal auffindbar + $year = (int)date('Y'); + $month = (int)date('n'); + $journal = \Steuerkram\Models\Booking::journal(['year' => $year, 'month' => $month], 1, 999); + + $foundCount = 0; + foreach ($journal['rows'] as $row) { + if (in_array((int)$row['id'], $buchungIds, true)) { + $foundCount++; + } + } + $this->assertEquals(count($buchungIds), $foundCount, 'Alle Buchungszeilen im Buchungsjournal'); + + // Buchungsinhalt prüfen (erste Buchung) + $firstId = $buchungIds[0]; + foreach ($journal['rows'] as $row) { + if ((int)$row['id'] === $firstId) { + $this->assertEquals(1000.00, (float)$row['betrag_netto'], 'Nettobetrag 1000 € korrekt'); + $this->assertEquals(19.0, (float)$row['ust_satz'], 'USt-Satz 19 % korrekt'); + $this->assertEquals(190.00, (float)$row['betrag_ust'], 'USt-Betrag 190 € korrekt'); + $this->assertEquals(1190.00, (float)$row['betrag_brutto'], 'Bruttobetrag 1190 € korrekt'); + $this->assertNotEmpty($row['beleg_nr'], 'Belegnummer im Journal vorhanden'); + $this->assertNotEmpty($row['soll_nr'], 'Soll-Konto im Journal vorhanden'); + $this->assertNotEmpty($row['haben_nr'], 'Haben-Konto im Journal vorhanden'); + break; + } + } + } +} diff --git a/tests/05_TaxSummenTest.php b/tests/05_TaxSummenTest.php new file mode 100644 index 0000000..4b78ab9 --- /dev/null +++ b/tests/05_TaxSummenTest.php @@ -0,0 +1,159 @@ +findAccountId('1400'); // Forderungen (Aktiv) + $erloesId = $this->findAccountId('8000'); // Erlöse 19 % (Ertrag) + $aufwandId = $this->findAccountId('4920'); // Bürobedarf (Aufwand) + $bankId = $this->findAccountId('1200'); // Bank (Aktiv) + + // Fallback auf beliebige Konten der passenden Art + if (!$erloesId) { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Ertrag' AND aktiv = 1 LIMIT 1" + ); + $erloesId = $row ? (int)$row['id'] : null; + } + if (!$aufwandId) { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Aufwand' AND aktiv = 1 LIMIT 1" + ); + $aufwandId = $row ? (int)$row['id'] : null; + } + if (!$debitorId) { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 LIMIT 1" + ); + $debitorId = $row ? (int)$row['id'] : null; + } + if (!$bankId) { + $row = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Aktiv' AND aktiv = 1 AND id != ? LIMIT 1", + [$debitorId ?? 0] + ); + $bankId = $row ? (int)$row['id'] : null; + } + + if (!$debitorId || !$erloesId || !$aufwandId || !$bankId) { + $this->assert(false, 'Benötigte Konten nicht gefunden – SKR03-Seed vorhanden?'); + return; + } + $this->assert(true, 'Alle benötigten Konten gefunden'); + + // ── Ausgangsbuchung (Ertrag, 19 %) ──────────────────────────────── + // Debitor an Erlös 19%: Netto=500, USt=95, Brutto=595 + $belegAr = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'TEST-AR-' . uniqid(), + 'filename' => 'test-ar.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-ar'), + 'beleg_datum' => $datum, + 'eingangs_datum' => $datum, + 'belegtyp' => 'Ausgangsrechnung', + ]); + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegAr, + 'buchungs_datum' => $datum, + 'soll_konto_id' => $debitorId, + 'haben_konto_id' => $erloesId, + 'betrag_netto' => 500.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test Ausgangsbuchung USt 19%', + ]); + + // ── Eingangsbuchung (Aufwand, 19 %) ─────────────────────────────── + // Aufwand an Bank: Netto=200, USt=38, Brutto=238 + $belegEr = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'TEST-ER-' . uniqid(), + 'filename' => 'test-er.pdf', + 'storage_path' => 'static/uploads/test/test-er.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'test-er'), + 'beleg_datum' => $datum, + 'eingangs_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', + ]); + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegEr, + 'buchungs_datum' => $datum, + 'soll_konto_id' => $aufwandId, + 'haben_konto_id' => $bankId, + 'betrag_netto' => 200.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test Eingangsbuchung USt 19%', + ]); + + // ── UStVA prüfen ────────────────────────────────────────────────── + $calc = new \Steuerkram\Services\UstvaCalculator(); + $kz = $calc->calculate($year, $month); + + // Kz 81: Steuerpfl. Umsätze 19% – muss mindestens 500 € enthalten + $this->assert( + (float)$kz['81']['betrag'] >= 500.00, + 'UStVA Kz 81 (Umsätze 19%): Betrag enthält Ausgangsbuchung ≥ 500 €' + ); + + // Kz 83: USt auf Umsätze 19% – abgeleitet (19% von Kz81-Betrag) + $this->assert( + (float)$kz['83']['steuer'] >= 95.00, + 'UStVA Kz 83 (USt 19%): Steuerbetrag ≥ 95 €' + ); + + // Kz 61: Vorsteuer 19% – muss mindestens 38 € enthalten + $this->assert( + (float)$kz['61']['steuer'] >= 38.00, + 'UStVA Kz 61 (Vorsteuer 19%): Steuerbetrag enthält Eingangsbuchung ≥ 38 €' + ); + + // 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(); + $euer = $euerCalc->calculate($year); + + // Einnahmen müssen vorhanden sein + $this->assert( + (float)$euer['total_einnahmen'] >= 500.00, + 'EÜR: Einnahmen ≥ 500 € (Ausgangsbuchung eingeflossen)' + ); + + // Ausgaben müssen vorhanden sein + $this->assert( + (float)$euer['total_ausgaben'] >= 200.00, + 'EÜR: Ausgaben ≥ 200 € (Eingangsbuchung eingeflossen)' + ); + + // Gewinn = Einnahmen - Ausgaben + $erwGewinn = round((float)$euer['total_einnahmen'] - (float)$euer['total_ausgaben'], 2); + $this->assertEquals($erwGewinn, (float)$euer['gewinn'], 'EÜR: Gewinn = Einnahmen - Ausgaben'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fd46558 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,115 @@ +passed++; + echo " \e[32m✓\e[0m {$message}\n"; + } else { + $this->failed++; + $this->failures[] = "[{$this->testName}] {$message}"; + echo " \e[31m✗\e[0m {$message}\n"; + } + } + + protected function assertEquals(mixed $expected, mixed $actual, string $message): void + { + $this->assert( + $expected == $actual, + "{$message} (erwartet: " . json_encode($expected) . ", erhalten: " . json_encode($actual) . ")" + ); + } + + protected function assertGreaterThan(int $min, mixed $actual, string $message): void + { + $this->assert($actual > $min, "{$message} (Wert: {$actual}, muss > {$min})"); + } + + protected function assertNotEmpty(mixed $value, string $message): void + { + $this->assert(!empty($value), $message); + } + + /** + * Liefert Konto-ID anhand der Kontonummer oder null wenn nicht gefunden. + */ + protected function findAccountId(string $kontonummer): ?int + { + $row = \Steuerkram\Database::fetchOne( + 'SELECT id FROM accounts WHERE kontonummer = ?', + [$kontonummer] + ); + return $row ? (int)$row['id'] : null; + } + + public function execute(): array + { + echo "\n\e[1m{$this->testName}\e[0m\n"; + $this->setUp(); + try { + $this->run(); + } catch (\Throwable $e) { + $this->failed++; + $msg = "[{$this->testName}] Ausnahme: " . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(); + $this->failures[] = $msg; + echo " \e[31m✗\e[0m Unerwartete Ausnahme: " . $e->getMessage() . "\n"; + } finally { + $this->tearDown(); + } + + $status = $this->failed === 0 + ? "\e[32mBestanden ({$this->passed}/{$this->passed})\e[0m" + : "\e[31m{$this->failed} Fehler, {$this->passed} OK\e[0m"; + echo " → {$status}\n"; + + self::$totalPassed += $this->passed; + self::$totalFailed += $this->failed; + self::$allFailures = array_merge(self::$allFailures, $this->failures); + + return $this->failures; + } + + public static function globalSummary(): array + { + return [ + 'passed' => self::$totalPassed, + 'failed' => self::$totalFailed, + 'failures' => self::$allFailures, + ]; + } +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..2a1b4de --- /dev/null +++ b/tests/run.php @@ -0,0 +1,90 @@ +#!/usr/bin/env php +execute(); + $allFailures = array_merge($allFailures, $failures); +} + +$summary = TestCase::globalSummary(); + +if (!$jsonMode) { + $total = $summary['passed'] + $summary['failed']; + echo "\n\e[1m══════════════════════════════════════════\e[0m\n"; + if ($summary['failed'] === 0) { + echo "\e[32;1m✓ Alle {$summary['passed']} Tests bestanden.\e[0m\n"; + } else { + echo "\e[31;1m✗ {$summary['failed']} von {$total} Tests fehlgeschlagen:\e[0m\n"; + foreach ($summary['failures'] as $failure) { + echo " • {$failure}\n"; + } + } + echo "\e[1m══════════════════════════════════════════\e[0m\n\n"; +} else { + echo json_encode([ + 'passed' => $summary['passed'], + 'failed' => $summary['failed'], + 'failures' => $summary['failures'], + ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + echo "\n"; +} + +exit($summary['failed'] > 0 ? 1 : 0);