Merge pull request 'Fix #159: Überlange Methoden zerlegt (FinTsFetchService, Booking, Bank-/InvoiceController)' (#182) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/182
This commit is contained in:
fds2610 2026-08-11 07:19:10 +02:00
commit 7f4d5eb47e
7 changed files with 967 additions and 450 deletions

View file

@ -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`).

View file

@ -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

View file

@ -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);
}
}

View file

@ -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().

View file

@ -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];
}
/**

View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\FinTsFetchService;
/**
* Test 88: FinTsFetchService – Hilfsmethoden ohne Bankverbindung.
*
* Deckt die aus der 282-Zeilen-Methode run() (Fix #159) extrahierten
* Teilschritte ab, die ohne echte FinTS-Verbindung testbar sind:
* validateConfig(), applyAccountFilter() und accountId().
*/
class FinTsHilfsmethodenTest extends TestCase
{
protected string $testName = 'Test 88: FinTsFetchService – Hilfsmethoden';
public function run(): void
{
$this->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');
}
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Database;
/**
* Test 89: Booking::create() – die drei Schreibpfade (Fix #159).
*
* Deckt die aus create() extrahierten Teilschritte ab:
* - insertHauptbuchung(): einfache Buchung ohne 7/19%-USt und ohne ig. Erwerb
* - insertVorsteuerUmsatzsteuerZeile(): normale Buchung mit 19% USt/VSt
* - insertIgErwerbReverseCharge(): ig. Erwerb 19% mit zwei Kindzeilen
*/
class BookingCreatePfadeTest extends TestCase
{
protected string $testName = 'Test 89: Booking::create() – Schreibpfade';
public function run(): void
{
$zahlungsId = $this->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 €');
}
}