From 74b9f0cc17e543cff4c3195b66c4c1a06258af20 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 07:17:41 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#159:=20=C3=9Cberlange=20Methoden=20zerle?= =?UTF-8?q?gt=20(FinTsFetchService::run,=20Booking::create/reverse,=20Bank?= =?UTF-8?q?-/InvoiceController)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 10 + src/Controllers/BankController.php | 212 +++++++----- src/Controllers/InvoiceController.php | 85 +++-- src/Models/Booking.php | 380 +++++++++++---------- src/Services/FinTsFetchService.php | 457 +++++++++++++++++--------- tests/88_FinTsHilfsmethodenTest.php | 143 ++++++++ tests/89_BookingCreatePfadeTest.php | 130 ++++++++ 7 files changed, 967 insertions(+), 450 deletions(-) create mode 100644 tests/88_FinTsHilfsmethodenTest.php create mode 100644 tests/89_BookingCreatePfadeTest.php diff --git a/Changes.md b/Changes.md index 9320679..7b70671 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,16 @@ --- +## 2026-08-11 – Überlange Methoden zerlegt: FinTsFetchService::run(), Booking::create()/reverse(), Bank-/InvoiceController (#159) + +- Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert. +- **`src/Services/FinTsFetchService.php`:** `run()` war mit 274 Zeilen der größte Ausreißer im Projekt und dabei von außen nicht testbar, weil sie eine echte Bankverbindung voraussetzt. Aufgeteilt in `validateConfig()`, `assertUrlSafe()`, `connect()`, `selectTan()` (mit `selectTanMode()`/`selectTanMedium()`), `login()`, `resolveAccounts()` (mit `fetchAccounts()`/`applyAccountFilter()`), `computeFetchRange()` sowie `fetchAllStatements()`/`fetchStatementsForAccount()`/`fetchAccountBalance()`/`importTransactions()`. `run()` liegt jetzt bei 50 Zeilen. Die doppelte SSRF-Prüfung aus Issue #130 (DNS-Rebinding-Fenster zwischen Validierung und Verbindungsaufbau) ist unverändert erhalten, der erklärende Kommentar sitzt weiterhin direkt am zweiten `assertUrlSafe()`-Aufruf in `run()`. +- **`src/Models/Booking.php`:** `create()` (144 Zeilen) und `reverse()` (95 Zeilen) enthielten mehrere Schreibpfade im selben Transaktionsblock. Die tatsächlichen Pfade in `create()` sind Hauptbuchung, eine VSt-/USt-Korrekturzeile für normale Buchungen mit 7/19 % (nicht „Eigenanteil“, wie im Issue-Text vermutet – dieser Begriff bezeichnet im Projekt die 30-%-Bewirtungskürzung aus `upsertEigenanteil30()`, ein unabhängiger Pfad außerhalb von `create()`) sowie die ig.-Erwerb-19-%-ReverseCharge-Kindzeilen (kein „Split“ im Sinne von `bucheBewirtungssplit()`). Extrahiert nach `insertHauptbuchung()`, `insertVorsteuerUmsatzsteuerZeile()`, `insertIgErwerbReverseCharge()`; `reverse()` entsprechend nach `insertHauptstorno()`/`insertKindstornos()`. Die Transaktionsklammer bleibt jeweils in `create()`/`reverse()`. +- **`src/Controllers/BankController.php`:** `direktStore()` (111 Zeilen) aufgeteilt in `storeCsvFormat()`, `resolveProviderId()`, `storeBankSource()`; `direktUpdate()` (70 Zeilen) in `buildUpdateData()`, `buildApiUpdateData()`, `buildFintsUpdateData()`. +- **`src/Controllers/InvoiceController.php`:** `storeBooking()` (84 Zeilen, drei identische Render-Blöcke für Validierungs-/PDO-/allgemeine Fehler) aufgeteilt in `validateBookingInput()`, `renderBookForm()` (bündelt die vormals dreifach kopierte View-Rendering-Logik), `persistBooking()`. +- **`tests/88_FinTsHilfsmethodenTest.php`** (neu): `validateConfig()` gegen fehlende BLZ/Benutzerkennung/PIN/URL, `applyAccountFilter()` mit leerer/passender/nicht passender Filterliste, `accountId()` für Konten mit/ohne IBAN und mit/ohne Unterkonto – alles ohne echte Bankverbindung über Reflection auf die privaten Methoden. +- **`tests/89_BookingCreatePfadeTest.php`** (neu): Deckt die drei Schreibpfade von `Booking::create()` ab – reine Hauptbuchung, Hauptbuchung mit VSt-Korrekturzeile (19 %), ig.-Erwerb-19-%-Buchung mit zwei Kindzeilen. + ## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158) - **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`). diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index ead539b..f674a78 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -405,60 +405,12 @@ class BankController extends BaseController // Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt // anschließend unter /settings/bankimport. if ($providerRaw === '__csv__') { - $kuerzel = trim($_POST['kuerzel'] ?? ''); - if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { - View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); - return; - } - - $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; - $delimiter = (string)($_POST['csv_delimiter'] ?? ';'); - $enclosure = (string)($_POST['csv_enclosure'] ?? '"'); - $dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y'); - $decSep = (string)($_POST['csv_decimal_separator'] ?? ','); - $thouSep = (string)($_POST['csv_thousands_separator'] ?? '.'); - $farbe = (string)($_POST['farbe'] ?? ''); - - $id = BankSource::create([ - 'type' => 'csv', - 'bezeichnung' => $bezeichnung, - 'kuerzel' => $kuerzel, - 'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1', - 'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))), - 'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1, - 'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';', - 'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"', - 'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y', - 'decimal_separator' => mb_strlen($decSep) === 1 ? $decSep : ',', - 'thousands_separator' => mb_strlen($thouSep) <= 1 ? $thouSep : '.', - 'col_buchungstag' => 0, - 'col_wertstellung' => 1, - 'col_vorgang' => 2, - 'col_buchungstext' => 0, - 'col_wert' => 4, - ]); - - View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.'); + $this->storeCsvFormat($bezeichnung); return; } - // Neue Bank anlegen, wenn Dropdown auf "__new__" steht. - // Verbindungstyp wählt der Nutzer im Formular (API oder FinTS). - if ($providerRaw === '__new__') { - $newName = trim((string)($_POST['new_bank_name'] ?? '')); - $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; - if ($newName === '') { - View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); - return; - } - $existing = BankProvider::findByName($newName); - $providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp); - } else { - $providerId = (int)$providerRaw; - } - - if (!$providerId) { - View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); + $providerId = $this->resolveProviderId($providerRaw); + if ($providerId === null) { return; } @@ -468,6 +420,73 @@ class BankController extends BaseController return; } + $this->storeBankSource($provider, $providerId, $bezeichnung); + } + + private function storeCsvFormat(string $bezeichnung): void + { + $kuerzel = trim($_POST['kuerzel'] ?? ''); + if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { + View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); + return; + } + + $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; + $delimiter = (string)($_POST['csv_delimiter'] ?? ';'); + $enclosure = (string)($_POST['csv_enclosure'] ?? '"'); + $dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y'); + $decSep = (string)($_POST['csv_decimal_separator'] ?? ','); + $thouSep = (string)($_POST['csv_thousands_separator'] ?? '.'); + $farbe = (string)($_POST['farbe'] ?? ''); + + $id = BankSource::create([ + 'type' => 'csv', + 'bezeichnung' => $bezeichnung, + 'kuerzel' => $kuerzel, + 'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1', + 'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))), + 'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1, + 'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';', + 'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"', + 'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y', + 'decimal_separator' => mb_strlen($decSep) === 1 ? $decSep : ',', + 'thousands_separator' => mb_strlen($thouSep) <= 1 ? $thouSep : '.', + 'col_buchungstag' => 0, + 'col_wertstellung' => 1, + 'col_vorgang' => 2, + 'col_buchungstext' => 0, + 'col_wert' => 4, + ]); + + View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.'); + } + + /** Neue Bank anlegen, wenn Dropdown auf "__new__" steht, sonst vorhandenen Anbieter übernehmen. */ + private function resolveProviderId(string $providerRaw): ?int + { + if ($providerRaw === '__new__') { + $newName = trim((string)($_POST['new_bank_name'] ?? '')); + $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; + if ($newName === '') { + View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); + return null; + } + $existing = BankProvider::findByName($newName); + $providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp); + } else { + $providerId = (int)$providerRaw; + } + + if (!$providerId) { + View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); + return null; + } + + return $providerId; + } + + private function storeBankSource(array $provider, int $providerId, string $bezeichnung): void + { $isApi = $provider['verbindungstyp'] === 'API'; $isFints = $provider['verbindungstyp'] === 'FinTS'; @@ -572,6 +591,17 @@ class BankController extends BaseController return; } + $data = $this->buildUpdateData($source, $bezeichnung); + if ($data === null) { + return; + } + + BankSource::update($id, $data); + View::redirect('/bank/direkt', 'Verbindung gespeichert.'); + } + + private function buildUpdateData(array $source, string $bezeichnung): ?array + { $isApi = $source['type'] === 'api'; $isFints = $source['type'] === 'fints'; @@ -587,43 +617,57 @@ class BankController extends BaseController } if ($isApi) { - $newPwd = trim($_POST['client_id'] ?? ''); - $newSecret = trim($_POST['secret'] ?? ''); - $data += [ - 'sandbox' => isset($_POST['sandbox']) ? 1 : 0, - 'app_name' => trim($_POST['app_name'] ?? '') ?: null, - 'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'], - 'secret' => $newSecret !== '' ? $newSecret : $source['secret'], - ]; + $data += $this->buildApiUpdateData($source); } elseif ($isFints) { - $newPin = trim($_POST['pin'] ?? ''); - $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; - if ($fintsUrl !== null) { - try { - SsrfGuard::assertPublicHttpsUrl($fintsUrl); - } catch (\RuntimeException $e) { - View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); - return; - } - } - $data += [ - 'blz' => trim($_POST['blz'] ?? '') ?: null, - 'fints_url' => $fintsUrl, - 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, - 'pin' => $newPin !== '' ? $newPin : $source['pin'], - 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, - ]; - $filterUpdate = BankSource::computeFilterFromPost( - $source['account_filter'] ?? null, - $_POST - ); - if ($filterUpdate['update']) { - $data['account_filter'] = $filterUpdate['value']; + $fintsData = $this->buildFintsUpdateData($source); + if ($fintsData === null) { + return null; } + $data += $fintsData; } - BankSource::update($id, $data); - View::redirect('/bank/direkt', 'Verbindung gespeichert.'); + return $data; + } + + private function buildApiUpdateData(array $source): array + { + $newPwd = trim($_POST['client_id'] ?? ''); + $newSecret = trim($_POST['secret'] ?? ''); + return [ + 'sandbox' => isset($_POST['sandbox']) ? 1 : 0, + 'app_name' => trim($_POST['app_name'] ?? '') ?: null, + 'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'], + 'secret' => $newSecret !== '' ? $newSecret : $source['secret'], + ]; + } + + private function buildFintsUpdateData(array $source): ?array + { + $newPin = trim($_POST['pin'] ?? ''); + $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; + if ($fintsUrl !== null) { + try { + SsrfGuard::assertPublicHttpsUrl($fintsUrl); + } catch (\RuntimeException $e) { + View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); + return null; + } + } + $data = [ + 'blz' => trim($_POST['blz'] ?? '') ?: null, + 'fints_url' => $fintsUrl, + 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, + 'pin' => $newPin !== '' ? $newPin : $source['pin'], + 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, + ]; + $filterUpdate = BankSource::computeFilterFromPost( + $source['account_filter'] ?? null, + $_POST + ); + if ($filterUpdate['update']) { + $data['account_filter'] = $filterUpdate['value']; + } + return $data; } public function direktDelete(array $params): void diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 58aac2a..1005149 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -412,6 +412,22 @@ class InvoiceController extends BaseController } $data = $_POST; + $errors = $this->validateBookingInput($data); + + if ($errors) { + if (!empty($data['_from_modal'])) { + View::redirect("/invoices/{$id}", implode(' ', $errors), 'error'); + return; + } + $this->renderBookForm($invoice, $belegId, $errors, $data); + return; + } + + $this->persistBooking($id, $invoice, $belegId, $data); + } + + private function validateBookingInput(array $data): array + { $errors = []; if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld'; @@ -425,27 +441,28 @@ class InvoiceController extends BaseController $errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.'; } - if ($errors) { - if (!empty($data['_from_modal'])) { - View::redirect("/invoices/{$id}", implode(' ', $errors), 'error'); - return; - } - $bookings = Booking::forBeleg($belegId); - $accounts = Account::all(['aktiv' => 1]); - $view = new View(); - $view->render('layout/base.php', [ - 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), - 'content' => $this->renderView('invoices/book', [ - 'invoice' => $invoice, - 'bookings' => $bookings, - 'accounts' => $accounts, - 'formErrors' => $errors, - 'formData' => $data, - ]), - ]); - return; - } + return $errors; + } + private function renderBookForm(array $invoice, int $belegId, array $errors, array $data): void + { + $bookings = Booking::forBeleg($belegId); + $accounts = Account::all(['aktiv' => 1]); + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), + 'content' => $this->renderView('invoices/book', [ + 'invoice' => $invoice, + 'bookings' => $bookings, + 'accounts' => $accounts, + 'formErrors' => $errors, + 'formData' => $data, + ]), + ]); + } + + private function persistBooking(int $id, array $invoice, int $belegId, array $data): void + { try { $data['beleg_id'] = $belegId; $data['buchungstyp'] = 'Einnahme'; @@ -454,33 +471,9 @@ class InvoiceController extends BaseController View::redirect($returnUrl, 'Buchung erfasst.'); } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); - $bookings = Booking::forBeleg($belegId); - $accounts = Account::all(['aktiv' => 1]); - $view = new View(); - $view->render('layout/base.php', [ - 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), - 'content' => $this->renderView('invoices/book', [ - 'invoice' => $invoice, - 'bookings' => $bookings, - 'accounts' => $accounts, - 'formErrors' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], - 'formData' => $data, - ]), - ]); + $this->renderBookForm($invoice, $belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data); } catch (\Exception $e) { - $bookings = Booking::forBeleg($belegId); - $accounts = Account::all(['aktiv' => 1]); - $view = new View(); - $view->render('layout/base.php', [ - 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), - 'content' => $this->renderView('invoices/book', [ - 'invoice' => $invoice, - 'bookings' => $bookings, - 'accounts' => $accounts, - 'formErrors' => ['_exception' => $e->getMessage()], - 'formData' => $data, - ]), - ]); + $this->renderBookForm($invoice, $belegId, ['_exception' => $e->getMessage()], $data); } } diff --git a/src/Models/Booking.php b/src/Models/Booking.php index f04b1ad..7a83417 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -115,118 +115,11 @@ class Booking $steuerart = 'normal'; } - $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; - Database::beginTransaction(); try { - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $nr, - (int)$data['beleg_id'], - $assetId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$data['kategorie_konto_id'], - (int)$data['zahlungskonto_id'], - $netto, - (float)($data['ust_satz'] ?? 0), - $steuerart, - $ust, - $brutto, - $data['buchungstext'], - $fy['id'], - ] - ); - - $mainId = (int)Database::lastInsertId(); - - // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen - $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); - if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true) - && in_array($ustSatzInt, [7, 19], true) - && $steuerart === 'normal' - && abs($ust) > 0.0 - ) { - $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE; - if ($isAusgabe) { - $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7; - $steuerText = 'Vorsteuer zu ' . $nr; - } else { - $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7; - $steuerText = 'USt zu ' . $nr; - } - $steuerAcc = Account::findByKontonummer($steuerKontonummer); - if ($steuerAcc) { - $steuerNr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $steuerNr, - (int)$data['beleg_id'], - $mainId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$steuerAcc['id'], - (int)$data['zahlungskonto_id'], - $ust, - 0.0, - 'normal', - 0.0, - $ust, - $steuerText, - $fy['id'], - ] - ); - } - } - - // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91) - if ($data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE - && $steuerart === 'ig_erwerb_19' - && abs($netto) > 0.0 - ) { - $igeUst = round($netto * 0.19, 2); - foreach ([ - [Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '], - [Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '], - ] as [$kontonr, $prefix]) { - $igeAcc = Account::findByKontonummer($kontonr); - if ($igeAcc) { - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - self::generateBuchungsNr(), - (int)$data['beleg_id'], - $mainId, - $data['buchungs_datum'], - Constants::BUCHUNGSTYP_AUSGABE, - (int)$igeAcc['id'], - (int)$data['zahlungskonto_id'], - $igeUst, - 0.0, - 'normal', - 0.0, - $igeUst, - $prefix . $nr, - $fy['id'], - ] - ); - } - } - } + $mainId = self::insertHauptbuchung($data, $fy, $nr, $steuerart, $netto, $ust, $brutto); + self::insertVorsteuerUmsatzsteuerZeile($data, $fy, $nr, $steuerart, $ust, $mainId); + self::insertIgErwerbReverseCharge($data, $fy, $nr, $steuerart, $netto, $mainId); Database::commit(); } catch (\Throwable $e) { @@ -239,6 +132,151 @@ class Booking return $mainId; } + private static function insertHauptbuchung( + array $data, + array $fy, + string $nr, + string $steuerart, + float $netto, + float $ust, + float $brutto + ): int { + $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; + + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $nr, + (int)$data['beleg_id'], + $assetId, + $data['buchungs_datum'], + $data['buchungstyp'], + (int)$data['kategorie_konto_id'], + (int)$data['zahlungskonto_id'], + $netto, + (float)($data['ust_satz'] ?? 0), + $steuerart, + $ust, + $brutto, + $data['buchungstext'], + $fy['id'], + ] + ); + + return (int)Database::lastInsertId(); + } + + /** VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen. */ + private static function insertVorsteuerUmsatzsteuerZeile( + array $data, + array $fy, + string $nr, + string $steuerart, + float $ust, + int $mainId + ): void { + $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); + if (!in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true) + || !in_array($ustSatzInt, [7, 19], true) + || $steuerart !== 'normal' + || abs($ust) <= 0.0 + ) { + return; + } + + $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE; + if ($isAusgabe) { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7; + $steuerText = 'Vorsteuer zu ' . $nr; + } else { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7; + $steuerText = 'USt zu ' . $nr; + } + $steuerAcc = Account::findByKontonummer($steuerKontonummer); + if (!$steuerAcc) { + return; + } + + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + self::generateBuchungsNr(), + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + $data['buchungstyp'], + (int)$steuerAcc['id'], + (int)$data['zahlungskonto_id'], + $ust, + 0.0, + 'normal', + 0.0, + $ust, + $steuerText, + $fy['id'], + ] + ); + } + + /** ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91). */ + private static function insertIgErwerbReverseCharge( + array $data, + array $fy, + string $nr, + string $steuerart, + float $netto, + int $mainId + ): void { + if ($data['buchungstyp'] !== Constants::BUCHUNGSTYP_AUSGABE + || $steuerart !== 'ig_erwerb_19' + || abs($netto) <= 0.0 + ) { + return; + } + + $igeUst = round($netto * 0.19, 2); + foreach ([ + [Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '], + [Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '], + ] as [$kontonr, $prefix]) { + $igeAcc = Account::findByKontonummer($kontonr); + if (!$igeAcc) { + continue; + } + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + self::generateBuchungsNr(), + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + Constants::BUCHUNGSTYP_AUSGABE, + (int)$igeAcc['id'], + (int)$data['zahlungskonto_id'], + $igeUst, + 0.0, + 'normal', + 0.0, + $igeUst, + $prefix . $nr, + $fy['id'], + ] + ); + } + } + public static function reverse(int $id, string $reason): int { $orig = self::find($id); @@ -263,64 +301,10 @@ class Booking Database::beginTransaction(); try { - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', - [ - $nr, - $reversalData['beleg_id'], - $reversalData['buchungs_datum'], - $reversalData['buchungstyp'], - $reversalData['kategorie_konto_id'], - $reversalData['zahlungskonto_id'], - -1 * (float)$orig['betrag_netto'], - $orig['ust_satz'], - $orig['steuerart'], - -1 * (float)$orig['betrag_ust'], - -1 * (float)$orig['betrag_brutto'], - $reversalData['buchungstext'], - $fy['id'], - ] - ); - - $reversalId = (int)Database::lastInsertId(); - + $reversalId = self::insertHauptstorno($orig, $reversalData, $fy, $nr); Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); - $vstBookings = Database::fetchAll( - 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', - [$id] - ); - foreach ($vstBookings as $vst) { - $vstNr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', - [ - $vstNr, - $vst['beleg_id'], - $reversalId, - date('Y-m-d'), - $vst['buchungstyp'], - $vst['kategorie_konto_id'], - $vst['zahlungskonto_id'], - -1 * (float)$vst['betrag_netto'], - $vst['ust_satz'], - $vst['steuerart'], - -1 * (float)$vst['betrag_ust'], - -1 * (float)$vst['betrag_brutto'], - 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, - $fy['id'], - ] - ); - $vstReversalId = (int)Database::lastInsertId(); - Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); - } + self::insertKindstornos($id, $reversalId, $reason, $fy); Database::commit(); } catch (\Throwable $e) { @@ -335,6 +319,70 @@ class Booking return $reversalId; } + private static function insertHauptstorno(array $orig, array $reversalData, array $fy, string $nr): int + { + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + [ + $nr, + $reversalData['beleg_id'], + $reversalData['buchungs_datum'], + $reversalData['buchungstyp'], + $reversalData['kategorie_konto_id'], + $reversalData['zahlungskonto_id'], + -1 * (float)$orig['betrag_netto'], + $orig['ust_satz'], + $orig['steuerart'], + -1 * (float)$orig['betrag_ust'], + -1 * (float)$orig['betrag_brutto'], + $reversalData['buchungstext'], + $fy['id'], + ] + ); + + return (int)Database::lastInsertId(); + } + + /** Storniert alle noch nicht stornierten Kindzeilen (VSt/USt, IGE) der Ursprungsbuchung. */ + private static function insertKindstornos(int $id, int $reversalId, string $reason, array $fy): void + { + $vstBookings = Database::fetchAll( + 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', + [$id] + ); + foreach ($vstBookings as $vst) { + $vstNr = self::generateBuchungsNr(); + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + [ + $vstNr, + $vst['beleg_id'], + $reversalId, + date('Y-m-d'), + $vst['buchungstyp'], + $vst['kategorie_konto_id'], + $vst['zahlungskonto_id'], + -1 * (float)$vst['betrag_netto'], + $vst['ust_satz'], + $vst['steuerart'], + -1 * (float)$vst['betrag_ust'], + -1 * (float)$vst['betrag_brutto'], + 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, + $fy['id'], + ] + ); + $vstReversalId = (int)Database::lastInsertId(); + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); + } + } + /** * Legt eine AfA-Buchung im eigenen AFA-Nummernkreis an. Referenz für Sperrprüfung, * Nummernvergabe innerhalb der Transaktion und Schreibumfang: self::create(). diff --git a/src/Services/FinTsFetchService.php b/src/Services/FinTsFetchService.php index a506521..1bf9809 100644 --- a/src/Services/FinTsFetchService.php +++ b/src/Services/FinTsFetchService.php @@ -13,6 +13,7 @@ use Fhp\Action\GetStatementOfAccount; use Fhp\BaseAction; use Fhp\FinTs; use Fhp\Model\StatementOfAccount\Transaction; +use Fhp\Model\TanMode; use Fhp\Model\TanRequestChallengeImage; use Fhp\Options\Credentials; use Fhp\Options\FinTsOptions; @@ -53,6 +54,122 @@ class FinTsFetchService { $log = $this->log; + $cfg = $this->validateConfig(); + if ($cfg === null) { + return; + } + if (!$this->assertUrlSafe($cfg['url'])) { + return; + } + $fints = $this->connect($cfg); + if ($fints === null) { + return; + } + // Erneute Prüfung unmittelbar vor dem ersten tatsächlichen Request: php-fints + // löst den Hostnamen beim Verbindungsaufbau selbst über den System-Resolver auf + // (kein IP-Pinning wie bei den selbst gesteuerten cURL-Aufrufen in + // NextcloudImportService möglich) – die zweite Prüfung minimiert das Zeitfenster + // für DNS-Rebinding zwischen Validierung und Verbindungsaufbau (siehe Issue #130). + if (!$this->assertUrlSafe($cfg['url'])) { + return; + } + + if (!$this->selectTan($fints)) { + return; + } + + if (!$this->login($fints)) { + return; + } + + $accounts = $this->resolveAccounts($fints); + if ($accounts === null) { + return; + } + + [$start, $end, $rangeLabel] = $this->computeFetchRange(); + $log('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr.'); + + $batchId = $this->createBatch(); + $log('Batch #' . $batchId . ' angelegt.'); + + [$totalNew, $totalSkip, $balance] = $this->fetchAllStatements($fints, $accounts, $start, $end, $batchId); + + $this->updateBatchCount($batchId, $totalNew); + $this->updateLastFetched($balance); + + $log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip)); + } + + /** Wählt TAN-Verfahren und ggf. TAN-Medium und registriert die Auswahl bei php-fints. */ + private function selectTan(FinTs $fints): bool + { + $log = $this->log; + + $selectedTanMode = $this->selectTanMode($fints); + if ($selectedTanMode === null) { + return false; + } + $selectedTanMedium = $this->selectTanMedium($fints, $selectedTanMode); + if ($selectedTanMedium === false) { + return false; + } + try { + $fints->selectTanMode($selectedTanMode, $selectedTanMedium); + } catch (\Throwable $e) { + $log('FEHLER bei selectTanMode: ' . $e->getMessage()); + return false; + } + return true; + } + + /** Kontenliste abrufen, Filter anwenden bzw. interaktive Auswahl einholen. */ + private function resolveAccounts(FinTs $fints): ?array + { + $log = $this->log; + + $accounts = $this->fetchAccounts($fints); + if ($accounts === null) { + return null; + } + $accounts = $this->applyAccountFilter($accounts); + if ($accounts === null) { + return null; + } + if (empty($accounts)) { + $log('Kein Konto entspricht der Auswahl – Abbruch.'); + return null; + } + return $accounts; + } + + /** @return array{0:int,1:int,2:?float} [gesamt neu, gesamt übersprungen, Gesamtsaldo] */ + private function fetchAllStatements(FinTs $fints, array $accounts, \DateTime $start, \DateTime $end, int $batchId): array + { + $totalNew = 0; + $totalSkip = 0; + $balance = null; + foreach ($accounts as $account) { + [$accNew, $accSkip, $accBalance] = $this->fetchStatementsForAccount($fints, $account, $start, $end, $batchId); + $totalNew += $accNew; + $totalSkip += $accSkip; + if ($accBalance !== null) { + $balance = ($balance ?? 0.0) + $accBalance; + } + } + return [$totalNew, $totalSkip, $balance]; + } + + /** + * Liest BLZ, Benutzerkennung, PIN und URL aus der Bankverbindung, loggt sie + * und prüft, dass keines der Pflichtfelder leer ist. + * + * @return array{blz:string,user:string,pin:string,url:string}|null null bei fehlenden Pflichtfeldern + */ + private function validateConfig(): ?array + { + $log = $this->log; + $blz = trim((string)($this->source['blz'] ?? '')); $user = trim((string)($this->source['benutzerkennung'] ?? '')); $pin = trim((string)($this->source['pin'] ?? '')); @@ -70,54 +187,61 @@ class FinTsFetchService if ($blz === '' || $user === '' || $pin === '' || $url === '') { $log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).'); - return; + return null; } + return ['blz' => $blz, 'user' => $user, 'pin' => $pin, 'url' => $url]; + } + + /** SSRF-Prüfung der FinTS-URL (siehe Issue #130 – wird bewusst zweimal aufgerufen). */ + private function assertUrlSafe(string $url): bool + { + $log = $this->log; try { SsrfGuard::assertPublicHttpsUrl($url); + return true; } catch (\Throwable $e) { $log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage()); - return; + return false; } + } + + /** @param array{blz:string,user:string,pin:string,url:string} $cfg */ + private function connect(array $cfg): ?FinTs + { + $log = $this->log; $options = new FinTsOptions(); - $options->url = $url; - $options->bankCode = $blz; + $options->url = $cfg['url']; + $options->bankCode = $cfg['blz']; $options->productName = 'F399A6377D4D60E0A222D677A'; $options->productVersion = '1.0'; - $credentials = Credentials::create($user, $pin); + $credentials = Credentials::create($cfg['user'], $cfg['pin']); try { $this->fints = FinTs::new($options, $credentials); } catch (\Throwable $e) { $log('FEHLER bei Initialisierung: ' . $e->getMessage()); - return; + return null; } - $fints = $this->fints; + return $this->fints; + } - // Erneute Prüfung unmittelbar vor dem ersten tatsächlichen Request: php-fints - // löst den Hostnamen beim Verbindungsaufbau selbst über den System-Resolver auf - // (kein IP-Pinning wie bei den selbst gesteuerten cURL-Aufrufen in - // NextcloudImportService möglich) – die zweite Prüfung minimiert das Zeitfenster - // für DNS-Rebinding zwischen Validierung und Verbindungsaufbau (siehe Issue #130). - try { - SsrfGuard::assertPublicHttpsUrl($url); - } catch (\Throwable $e) { - $log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage()); - return; - } + private function selectTanMode(FinTs $fints): ?TanMode + { + $log = $this->log; $log('TAN-Verfahren werden ermittelt…'); try { $tanModes = $fints->getTanModes(); } catch (\Throwable $e) { $log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage()); - return; + return null; } if (empty($tanModes)) { $log('FEHLER: Bank meldet keine TAN-Verfahren.'); - return; + return null; } foreach ($tanModes as $idx => $mode) { @@ -144,184 +268,209 @@ class FinTsFetchService } $log('Auswahl: ' . $selectedTanMode->getName() . ' (ID ' . $selectedTanMode->getId() . ')'); - $selectedTanMedium = null; - if ($selectedTanMode->needsTanMedium()) { - try { - $tanMedia = $fints->getTanMedia($selectedTanMode); - } catch (\Throwable $e) { - $log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage()); - return; - } - if (empty($tanMedia)) { - $log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.'); - return; - } - $selectedTanMedium = reset($tanMedia); - $log('TAN-Medium: ' . $selectedTanMedium->getName()); + return $selectedTanMode; + } + + /** + * @return mixed Das TAN-Medium, null wenn keines benötigt wird, oder false bei einem Fehler + * (dann wurde bereits geloggt und der Aufrufer soll abbrechen). + */ + private function selectTanMedium(FinTs $fints, TanMode $selectedTanMode): mixed + { + if (!$selectedTanMode->needsTanMedium()) { + return null; } + $log = $this->log; try { - $fints->selectTanMode($selectedTanMode, $selectedTanMedium); + $tanMedia = $fints->getTanMedia($selectedTanMode); } catch (\Throwable $e) { - $log('FEHLER bei selectTanMode: ' . $e->getMessage()); - return; + $log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage()); + return false; } + if (empty($tanMedia)) { + $log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.'); + return false; + } + $selectedTanMedium = reset($tanMedia); + $log('TAN-Medium: ' . $selectedTanMedium->getName()); + return $selectedTanMedium; + } + + private function login(FinTs $fints): bool + { + $log = $this->log; $log('Login wird versucht…'); try { $login = $fints->login(); if ($login->needsTan()) { if (!$this->handleStrongAuthentication($login, 'Login')) { - return; + return false; } } } catch (\Throwable $e) { $log('FEHLER beim Login: ' . $e->getMessage()); - return; + return false; } $log('Login OK.'); + return true; + } + + private function fetchAccounts(FinTs $fints): ?array + { + $log = $this->log; try { $getAccounts = GetSEPAAccounts::create(); $fints->execute($getAccounts); if ($getAccounts->needsTan()) { $log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.'); - return; + return null; } $accounts = $getAccounts->getAccounts(); } catch (\Throwable $e) { $log('FEHLER bei Kontenabfrage: ' . $e->getMessage()); - return; + return null; } $log(count($accounts) . ' SEPA-Konten verfügbar.'); + return $accounts; + } - $accounts = $this->applyAccountFilter($accounts); - if ($accounts === null) { - return; - } - if (empty($accounts)) { - $log('Kein Konto entspricht der Auswahl – Abbruch.'); - return; - } - + /** @return array{0:\DateTime,1:\DateTime,2:string} [Start, Ende, Anzeigetext] */ + private function computeFetchRange(): array + { $end = new \DateTime('today'); [$startImm, $rangeLabel] = BankSource::computeStartDate( $this->source['fetch_range'] ?? null, $this->source['last_fetched_at'] ?? null ); $start = \DateTime::createFromImmutable($startImm); - $log('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr.'); + return [$start, $end, $rangeLabel]; + } - $batchId = $this->createBatch(); - $log('Batch #' . $batchId . ' angelegt.'); + /** + * Ruft Kontoauszug und Saldo für ein einzelnes Konto ab und importiert neue Umsätze. + * + * @return array{0:int,1:int,2:?float} [neue Buchungen, übersprungene Duplikate, Saldo] + */ + private function fetchStatementsForAccount(FinTs $fints, $account, \DateTime $start, \DateTime $end, int $batchId): array + { + $log = $this->log; + $iban = $account->getIban(); + $log('Konto ' . $iban . ':'); - $totalNew = 0; - $totalSkip = 0; - $balance = null; - - foreach ($accounts as $account) { - $iban = $account->getIban(); - $log('Konto ' . $iban . ':'); - - try { - $action = GetStatementOfAccount::create($account, $start, $end, false, true); - $fints->execute($action); - if ($action->needsTan()) { - if (!$this->handleStrongAuthentication($action, $iban)) { - continue; - } - } - $soa = $action->getStatement(); - } catch (\Throwable $e) { - $log(' → Fehler beim Abruf: ' . $e->getMessage()); - continue; - } - - $accountBalance = null; - try { - $balAction = GetBalance::create($account); - $fints->execute($balAction); - if ($balAction->needsTan()) { - if (!$this->handleStrongAuthentication($balAction, $iban . ' (Saldo)')) { - throw new \RuntimeException('TAN für Saldenabfrage nicht bestätigt.'); - } - } - foreach ($balAction->getBalances() as $hisal) { - $accountBalance = ($accountBalance ?? 0.0) + $hisal->getGebuchterSaldo()->getAmount(); - } - } catch (\Throwable $e) { - $log(' → Saldenabfrage fehlgeschlagen: ' . $e->getMessage()); - } - - // Fallback: Saldo aus dem letzten Statement ableiten, falls die dedizierte - // Saldenabfrage (HKSAL) fehlschlägt oder von der Bank nicht unterstützt wird. - $statements = $soa->getStatements(); - if ($accountBalance === null && !empty($statements)) { - $lastStatement = end($statements); - $accountBalance = $lastStatement->getEndBalance() ?? $lastStatement->getStartBalance(); - } - - if ($accountBalance !== null) { - $balance = ($balance ?? 0.0) + $accountBalance; - $log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €'); - } - - $accountNew = 0; - foreach ($soa->getStatements() as $statement) { - foreach ($statement->getTransactions() as $tx) { - $date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime(); - $sign = $tx->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1; - $amt = round($sign * (float)$tx->getAmount(), 2); - - $name = trim((string)$tx->getName()); - $desc = trim((string)$tx->getMainDescription()); - $vorgang = trim((string)$tx->getBookingText()) ?: 'FinTS'; - - $extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc); - - if ($this->externalIdExists($extId)) { - $totalSkip++; - continue; - } - - if ($name !== '' && $desc !== '') { - $buchungstext = $name . ' | ' . $desc; - } elseif ($desc !== '') { - $buchungstext = $desc; - } elseif ($name !== '') { - $buchungstext = $name; - } else { - $buchungstext = $vorgang; - } - - $this->insertRow($batchId, [ - 'external_id' => $extId, - 'buchungstag' => ($tx->getBookingDate() ?: $date)->format('Y-m-d'), - 'wertstellung' => $date->format('Y-m-d'), - 'vorgang' => mb_substr($vorgang, 0, 100), - 'buchungstext' => mb_substr($buchungstext, 0, 1000), - 'wert' => $amt, - 'raw_line' => json_encode([ - 'iban' => $iban, - 'booking_text' => $tx->getBookingText(), - 'name' => $name, - 'desc' => $desc, - 'amount' => $amt, - 'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null, - ], JSON_UNESCAPED_UNICODE), - ]); - $totalNew++; - $accountNew++; + try { + $action = GetStatementOfAccount::create($account, $start, $end, false, true); + $fints->execute($action); + if ($action->needsTan()) { + if (!$this->handleStrongAuthentication($action, $iban)) { + return [0, 0, null]; } } - $log(' → ' . $accountNew . ' neue Buchungen.'); + $soa = $action->getStatement(); + } catch (\Throwable $e) { + $log(' → Fehler beim Abruf: ' . $e->getMessage()); + return [0, 0, null]; } - $this->updateBatchCount($batchId, $totalNew); - $this->updateLastFetched($balance); + $accountBalance = $this->fetchAccountBalance($fints, $account, $iban, $soa); + [$new, $skip] = $this->importTransactions($soa, $iban, $batchId); - $log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip)); + return [$new, $skip, $accountBalance]; + } + + private function fetchAccountBalance(FinTs $fints, $account, string $iban, $soa): ?float + { + $log = $this->log; + + $accountBalance = null; + try { + $balAction = GetBalance::create($account); + $fints->execute($balAction); + if ($balAction->needsTan()) { + if (!$this->handleStrongAuthentication($balAction, $iban . ' (Saldo)')) { + throw new \RuntimeException('TAN für Saldenabfrage nicht bestätigt.'); + } + } + foreach ($balAction->getBalances() as $hisal) { + $accountBalance = ($accountBalance ?? 0.0) + $hisal->getGebuchterSaldo()->getAmount(); + } + } catch (\Throwable $e) { + $log(' → Saldenabfrage fehlgeschlagen: ' . $e->getMessage()); + } + + // Fallback: Saldo aus dem letzten Statement ableiten, falls die dedizierte + // Saldenabfrage (HKSAL) fehlschlägt oder von der Bank nicht unterstützt wird. + $statements = $soa->getStatements(); + if ($accountBalance === null && !empty($statements)) { + $lastStatement = end($statements); + $accountBalance = $lastStatement->getEndBalance() ?? $lastStatement->getStartBalance(); + } + + if ($accountBalance !== null) { + $log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €'); + } + + return $accountBalance; + } + + /** @return array{0:int,1:int} [neue Buchungen, übersprungene Duplikate] */ + private function importTransactions($soa, string $iban, int $batchId): array + { + $log = $this->log; + $new = 0; + $skip = 0; + + foreach ($soa->getStatements() as $statement) { + foreach ($statement->getTransactions() as $tx) { + $date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime(); + $sign = $tx->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1; + $amt = round($sign * (float)$tx->getAmount(), 2); + + $name = trim((string)$tx->getName()); + $desc = trim((string)$tx->getMainDescription()); + $vorgang = trim((string)$tx->getBookingText()) ?: 'FinTS'; + + $extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc); + + if ($this->externalIdExists($extId)) { + $skip++; + continue; + } + + if ($name !== '' && $desc !== '') { + $buchungstext = $name . ' | ' . $desc; + } elseif ($desc !== '') { + $buchungstext = $desc; + } elseif ($name !== '') { + $buchungstext = $name; + } else { + $buchungstext = $vorgang; + } + + $this->insertRow($batchId, [ + 'external_id' => $extId, + 'buchungstag' => ($tx->getBookingDate() ?: $date)->format('Y-m-d'), + 'wertstellung' => $date->format('Y-m-d'), + 'vorgang' => mb_substr($vorgang, 0, 100), + 'buchungstext' => mb_substr($buchungstext, 0, 1000), + 'wert' => $amt, + 'raw_line' => json_encode([ + 'iban' => $iban, + 'booking_text' => $tx->getBookingText(), + 'name' => $name, + 'desc' => $desc, + 'amount' => $amt, + 'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null, + ], JSON_UNESCAPED_UNICODE), + ]); + $new++; + } + } + $log(' → ' . $new . ' neue Buchungen.'); + return [$new, $skip]; } /** diff --git a/tests/88_FinTsHilfsmethodenTest.php b/tests/88_FinTsHilfsmethodenTest.php new file mode 100644 index 0000000..bf5e67c --- /dev/null +++ b/tests/88_FinTsHilfsmethodenTest.php @@ -0,0 +1,143 @@ +testValidateConfigLehntLeereFelderAb(); + $this->testValidateConfigLiefertPflichtfelder(); + $this->testAccountId(); + $this->testApplyAccountFilter(); + } + + private function makeService(array $source): FinTsFetchService + { + return new FinTsFetchService($source, function (string $msg): void { + // Logs werden in diesem Test nicht ausgewertet. + }); + } + + private function callPrivate(object $obj, string $method, array $args = []): mixed + { + $ref = new \ReflectionMethod($obj, $method); + $ref->setAccessible(true); + return $ref->invokeArgs($obj, $args); + } + + private function testValidateConfigLehntLeereFelderAb(): void + { + $base = [ + 'id' => 1, + 'provider_name' => 'Testbank', + 'blz' => '12345678', + 'benutzerkennung' => 'user', + 'pin' => 'geheim', + 'fints_url' => 'https://example.test/fints', + ]; + + foreach (['blz', 'benutzerkennung', 'pin', 'fints_url'] as $missingField) { + $source = $base; + $source[$missingField] = ''; + $svc = $this->makeService($source); + $result = $this->callPrivate($svc, 'validateConfig'); + $this->assert($result === null, "validateConfig() lehnt leeres Feld '{$missingField}' ab"); + } + } + + private function testValidateConfigLiefertPflichtfelder(): void + { + $source = [ + 'id' => 1, + 'provider_name' => 'Testbank', + 'blz' => '12345678', + 'benutzerkennung' => 'user', + 'pin' => 'geheim', + 'fints_url' => 'https://example.test/fints', + ]; + $svc = $this->makeService($source); + $result = $this->callPrivate($svc, 'validateConfig'); + $this->assertEquals( + ['blz' => '12345678', 'user' => 'user', 'pin' => 'geheim', 'url' => 'https://example.test/fints'], + $result, + 'validateConfig() liefert die getrimmten Pflichtfelder bei vollständiger Konfiguration' + ); + } + + private function testAccountId(): void + { + $ref = new \ReflectionMethod(FinTsFetchService::class, 'accountId'); + $ref->setAccessible(true); + + $withIban = new class { + public function getIban(): string { return 'DE02120300000000202051'; } + public function getAccountNumber(): string { return '000000202051'; } + }; + $this->assertEquals( + 'DE02120300000000202051', + $ref->invoke(null, $withIban), + 'accountId() nutzt die IBAN, wenn vorhanden' + ); + + $withoutIbanNoSub = new class { + public function getIban(): string { return ''; } + public function getAccountNumber(): string { return '4711'; } + }; + $this->assertEquals( + 'AN:4711:', + $ref->invoke(null, $withoutIbanNoSub), + 'accountId() baut ohne IBAN einen AN:-Identifier aus der Kontonummer' + ); + + $withoutIbanWithSub = new class { + public function getIban(): string { return ''; } + public function getAccountNumber(): string { return '4711'; } + public function getSubAccount(): string { return '01'; } + }; + $this->assertEquals( + 'AN:4711:01', + $ref->invoke(null, $withoutIbanWithSub), + 'accountId() hängt die Unterkontonummer an, wenn vorhanden' + ); + } + + private function testApplyAccountFilter(): void + { + $accA = new class { + public function getIban(): string { return 'DE02120300000000202051'; } + public function getAccountNumber(): string { return '000000202051'; } + }; + $accB = new class { + public function getIban(): string { return 'DE11500105170648489890'; } + public function getAccountNumber(): string { return '0648489890'; } + }; + + // Leere Filterliste → kein Konto entspricht der Auswahl + $svc = $this->makeService(['id' => 1, 'account_filter' => '[]']); + $result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]); + $this->assertEquals([], $result, 'applyAccountFilter() mit leerer Filterliste liefert keine Konten'); + + // Passender Filter → nur das gefilterte Konto + $svc = $this->makeService(['id' => 1, 'account_filter' => json_encode(['DE02120300000000202051'])]); + $result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]); + $this->assertEquals(1, count($result), 'applyAccountFilter() mit passendem Filter liefert genau ein Konto'); + $this->assert(($result[0] ?? null) === $accA, 'applyAccountFilter() liefert das gefilterte Konto A'); + + // Nicht passender Filter → kein Konto entspricht der Auswahl + $svc = $this->makeService(['id' => 1, 'account_filter' => json_encode(['DE00000000000000000000'])]); + $result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]); + $this->assertEquals([], $result, 'applyAccountFilter() mit nicht passendem Filter liefert keine Konten'); + } +} diff --git a/tests/89_BookingCreatePfadeTest.php b/tests/89_BookingCreatePfadeTest.php new file mode 100644 index 0000000..3fd19d4 --- /dev/null +++ b/tests/89_BookingCreatePfadeTest.php @@ -0,0 +1,130 @@ +findAccountId('1200'); + $ausgabeId = $this->findAccountId('4120'); + + if (!$zahlungsId || !$ausgabeId) { + $this->assert(false, 'Benötigte Konten nicht gefunden (1200, 4120)'); + return; + } + $this->assert(true, 'Konten 1200, 4120 vorhanden'); + + $belegId = Beleg::create([ + 'beleg_nr' => 'BK-TEST-' . uniqid(), + 'filename' => 'bk-test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'bk-test-' . uniqid()), + 'beleg_datum' => date('Y-m-d'), + 'belegtyp' => 'Eingangsrechnung', + ]); + + $this->testNurHauptbuchung($belegId, $ausgabeId, $zahlungsId); + $this->testMitVorsteuerZeile($belegId, $ausgabeId, $zahlungsId); + $this->testMitIgErwerbKindzeilen($belegId, $ausgabeId, $zahlungsId); + } + + /** Buchung ohne 7/19%-USt und ohne ig. Erwerb → genau eine Zeile. */ + private function testNurHauptbuchung(int $belegId, int $ausgabeId, int $zahlungsId): void + { + $bookingId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 42.00, + 'ust_satz' => 0.0, + 'buchungstext' => 'Test ohne USt-Zeile', + ]); + + $rows = Database::fetchAll('SELECT * FROM bookings WHERE beleg_id = ? AND id = ?', [$belegId, $bookingId]); + $this->assertEquals(1, count($rows), 'Buchung ohne 7/19%-USt erzeugt genau eine Zeile'); + + $kinder = Database::fetchAll('SELECT * FROM bookings WHERE parent_booking_id = ?', [$bookingId]); + $this->assertEquals(0, count($kinder), 'Buchung ohne 7/19%-USt erzeugt keine Kindzeilen'); + } + + /** Normale Buchung mit 19% USt → Hauptbuchung plus VSt-Korrekturzeile. */ + private function testMitVorsteuerZeile(int $belegId, int $ausgabeId, int $zahlungsId): void + { + $bookingId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 100.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test mit VSt-Zeile', + ]); + + $kinder = Database::fetchAll( + 'SELECT bk.*, a.kontonummer FROM bookings bk + JOIN accounts a ON bk.kategorie_konto_id = a.id + WHERE bk.parent_booking_id = ?', + [$bookingId] + ); + $this->assertEquals(1, count($kinder), 'Buchung mit 19% USt erzeugt genau eine VSt-Korrekturzeile'); + + $vstZeile = $kinder[0] ?? null; + $this->assert($vstZeile !== null, 'VSt-Korrekturzeile vorhanden'); + if ($vstZeile !== null) { + $this->assertEquals('1576', $vstZeile['kontonummer'], 'VSt-Korrekturzeile bucht auf Konto 1576 (VSt 19%)'); + $this->assertEquals(19.00, round((float)$vstZeile['betrag_netto'], 2), 'VSt-Korrekturzeile: Betrag = 19,00 €'); + $this->assertEquals($bookingId, (int)$vstZeile['parent_booking_id'], 'VSt-Korrekturzeile referenziert die Hauptbuchung über parent_booking_id'); + } + } + + /** ig. Erwerb 19% → zwei Kindzeilen (VST_IGE_19 und UST_IGE_19). */ + private function testMitIgErwerbKindzeilen(int $belegId, int $ausgabeId, int $zahlungsId): void + { + $bookingId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => date('Y-m-d'), + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 1000.00, + 'ust_satz' => 0.0, + 'steuerart' => 'ig_erwerb_19', + 'buchungstext' => 'Test ig. Erwerb 19%', + ]); + + $kinder = Database::fetchAll( + 'SELECT bk.*, a.kontonummer FROM bookings bk + JOIN accounts a ON bk.kategorie_konto_id = a.id + WHERE bk.parent_booking_id = ? + ORDER BY a.kontonummer', + [$bookingId] + ); + $this->assertEquals(2, count($kinder), 'ig. Erwerb 19% erzeugt genau zwei Kindzeilen'); + + $summeKinder = 0.0; + foreach ($kinder as $kind) { + $this->assertEquals($bookingId, (int)$kind['parent_booking_id'], 'Kindzeile referenziert die Hauptbuchung'); + $summeKinder += (float)$kind['betrag_netto']; + } + $this->assertEquals(380.00, round($summeKinder, 2), 'Summe der Kindzeilen = 2 × 19% von 1000 € = 380 €'); + } +}