log nutzen, um SSE-Events mit eigenem Namen zu schicken */ private $send; public function __construct(array $source, callable $log, ?callable $send = null) { $this->source = $source; $this->sourceId = (int)$source['id']; $this->log = $log; $this->send = $send; } public function run(): void { $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'] ?? '')); $url = trim((string)($this->source['fints_url'] ?? '')); if ($url === '') { $url = trim((string)($this->source['default_fints_url'] ?? '')); } $log('Nutze FinTS-Direktverbindung'); $log('Bank: ' . ($this->source['provider_name'] ?? '?')); $log('BLZ: ' . ($blz !== '' ? $blz : 'FEHLT')); $log('URL: ' . ($url !== '' ? $url : 'FEHLT')); $log('User: ' . ($user !== '' ? $user : 'FEHLT')); $log('PIN: ' . ($pin !== '' ? strlen($pin) . ' Zeichen' : 'FEHLT')); if ($blz === '' || $user === '' || $pin === '' || $url === '') { $log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).'); 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 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 = $cfg['url']; $options->bankCode = $cfg['blz']; $options->productName = 'F399A6377D4D60E0A222D677A'; $options->productVersion = '1.0'; $credentials = Credentials::create($cfg['user'], $cfg['pin']); try { $this->fints = FinTs::new($options, $credentials); } catch (\Throwable $e) { $log('FEHLER bei Initialisierung: ' . $e->getMessage()); return null; } return $this->fints; } 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 null; } if (empty($tanModes)) { $log('FEHLER: Bank meldet keine TAN-Verfahren.'); return null; } foreach ($tanModes as $idx => $mode) { $log(sprintf(' [%d] %s (ID %d)', $idx, $mode->getName(), $mode->getId())); } $preferredId = isset($this->source['tan_mode_id']) && $this->source['tan_mode_id'] !== null ? (int)$this->source['tan_mode_id'] : null; $selectedTanMode = null; if ($preferredId !== null) { foreach ($tanModes as $mode) { if ($mode->getId() === $preferredId) { $selectedTanMode = $mode; break; } } if ($selectedTanMode === null) { $log('WARNUNG: TAN-Verfahren ID ' . $preferredId . ' nicht gefunden – nutze erstes verfügbares.'); } } if ($selectedTanMode === null) { $selectedTanMode = reset($tanModes); } $log('Auswahl: ' . $selectedTanMode->getName() . ' (ID ' . $selectedTanMode->getId() . ')'); 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 { $tanMedia = $fints->getTanMedia($selectedTanMode); } catch (\Throwable $e) { $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 false; } } } catch (\Throwable $e) { $log('FEHLER beim Login: ' . $e->getMessage()); 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 null; } $accounts = $getAccounts->getAccounts(); } catch (\Throwable $e) { $log('FEHLER bei Kontenabfrage: ' . $e->getMessage()); return null; } $log(count($accounts) . ' SEPA-Konten verfügbar.'); return $accounts; } /** @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); return [$start, $end, $rangeLabel]; } /** * 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 . ':'); try { $action = GetStatementOfAccount::create($account, $start, $end, false, true); $fints->execute($action); if ($action->needsTan()) { if (!$this->handleStrongAuthentication($action, $iban)) { return [0, 0, null]; } } $soa = $action->getStatement(); } catch (\Throwable $e) { $log(' → Fehler beim Abruf: ' . $e->getMessage()); return [0, 0, null]; } $accountBalance = $this->fetchAccountBalance($fints, $account, $iban, $soa); [$new, $skip] = $this->importTransactions($soa, $iban, $batchId); 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]; } /** * Behandelt eine TAN-Anfrage einer FinTS-Aktion. * Decoupled (pushTAN/App-Bestätigung) → Polling. * Klassisch (photoTAN/chipTAN) → Bild + Eingabefeld via SSE, Wartezeit auf Datei-Rendezvous. * * @return bool true, wenn die Aktion erfolgreich abgeschlossen wurde */ private function handleStrongAuthentication(BaseAction $action, string $contextLabel): bool { $log = $this->log; $send = $this->send ?: function (string $event, string $data) use ($log): void { // Fallback ohne SSE: nur loggen $log(' → [event:' . $event . '] ' . $data); }; $tanMode = $this->fints->getSelectedTanMode(); $tanRequest = $action->getTanRequest(); if ($tanMode->isDecoupled()) { $log(' → ' . $contextLabel . ': Bitte im Banking-Gerät bestätigen…'); if ($tanRequest->getChallenge() !== null) { $log(' Hinweis: ' . $tanRequest->getChallenge()); } if (!$tanMode->allowsAutomatedPolling()) { $log(' → Bank erlaubt kein automatisches Polling – aktuell nicht unterstützt.'); return false; } sleep((int)$tanMode->getFirstDecoupledCheckDelaySeconds()); $maxChecks = (int)$tanMode->getMaxDecoupledChecks(); $period = max(1, (int)$tanMode->getPeriodicDecoupledCheckDelaySeconds()); for ($attempt = 0; $attempt < $maxChecks; $attempt++) { try { if ($this->fints->checkDecoupledSubmission($action)) { return true; } } catch (\Throwable $e) { $log(' → Polling-Fehler: ' . $e->getMessage()); return false; } sleep($period); } $log(' → Zeitüberschreitung bei App-Bestätigung.'); return false; } // Klassisch: photoTAN/chipTAN – Bild zeigen, TAN entgegennehmen $token = bin2hex(random_bytes(16)); $payload = [ 'token' => $token, 'context' => $contextLabel, 'challenge' => $tanRequest->getChallenge(), 'mediumName' => $tanRequest->getTanMediumName(), 'imageMime' => null, 'imageData' => null, ]; if ($tanRequest->getChallengeHhdUc()) { try { $img = new TanRequestChallengeImage($tanRequest->getChallengeHhdUc()); $payload['imageMime'] = $img->getMimeType(); $payload['imageData'] = base64_encode($img->getData()); } catch (\Throwable) { // war kein photoTAN-Bild (z.B. Flicker) – ohne Bild fortfahren } } $log(' → ' . $contextLabel . ': TAN angefordert. Bitte im Browser eingeben…'); ($send)('tan-required', json_encode($payload, JSON_UNESCAPED_UNICODE)); $tan = self::awaitTan($token, 300); if ($tan === null) { $log(' → Zeitüberschreitung bei TAN-Eingabe.'); return false; } try { $this->fints->submitTan($action, $tan); $log(' → TAN akzeptiert.'); return true; } catch (\Throwable $e) { $log(' → TAN abgelehnt: ' . $e->getMessage()); return false; } } /** * Eindeutiger Identifier für ein FinTS-Konto. IBAN wenn vorhanden, sonst * "AN::". Robust gegen Depots ohne IBAN. */ private static function accountId($account): string { $iban = trim((string)$account->getIban()); if ($iban !== '') { return $iban; } $sub = method_exists($account, 'getSubAccount') ? (string)$account->getSubAccount() : ''; return 'AN:' . $account->getAccountNumber() . ':' . $sub; } /** * Wendet den gespeicherten account_filter an. Wenn kein Filter gesetzt und * mehr als ein Konto verfügbar ist, wird der Nutzer per SSE-Event nach einer * Auswahl gefragt und das Ergebnis persistiert. * * @return array|null Liste der erlaubten Konten oder null bei Abbruch */ private function applyAccountFilter(array $accounts): ?array { $log = $this->log; $send = $this->send ?: function (string $event, string $data) use ($log): void { $log(' → [event:' . $event . '] ' . $data); }; $rawFilter = $this->source['account_filter'] ?? null; $allowedIds = null; if ($rawFilter !== null && $rawFilter !== '') { $decoded = json_decode((string)$rawFilter, true); if (is_array($decoded)) { $allowedIds = array_values(array_map('strval', $decoded)); } } // Erste Verwendung & mehrere Konten: Auswahl vom Nutzer einholen if ($allowedIds === null && count($accounts) > 1) { $token = bin2hex(random_bytes(16)); $payload = [ 'token' => $token, 'accounts' => array_map(function ($a) { $iban = trim((string)$a->getIban()); $accNo = (string)$a->getAccountNumber(); $sub = method_exists($a, 'getSubAccount') ? (string)$a->getSubAccount() : ''; return [ 'id' => self::accountId($a), 'iban' => $iban, 'label' => $iban !== '' ? $iban : ('Konto ' . $accNo . ($sub !== '' ? ' (' . $sub . ')' : '')), ]; }, $accounts), ]; $log('Mehrere Konten verfügbar – bitte Auswahl im Browser treffen.'); ($send)('accounts-required', json_encode($payload, JSON_UNESCAPED_UNICODE)); $selected = self::awaitAccountSelection($token, 300); if ($selected === null) { $log('FEHLER: Zeitüberschreitung bei Konto-Auswahl.'); return null; } if (empty($selected)) { $log('FEHLER: Keine Konten ausgewählt.'); return null; } $allowedIds = $selected; Database::execute( 'UPDATE bank_sources SET account_filter = ? WHERE id = ?', [json_encode(array_values($allowedIds), JSON_UNESCAPED_UNICODE), $this->sourceId] ); $log('Konto-Auswahl gespeichert: ' . count($allowedIds) . ' Konto(n).'); } if ($allowedIds === null) { return $accounts; } $filtered = array_values(array_filter( $accounts, fn($a) => in_array(self::accountId($a), $allowedIds, true) )); $log('Filter aktiv: ' . count($filtered) . ' Konto(n) verbleiben.'); return $filtered; } private static function tanDir(): string { $dir = sys_get_temp_dir() . '/steuerkram-tan'; if (!is_dir($dir)) { @mkdir($dir, 0700, true); } return $dir; } /** Wird vom POST-Endpoint aufgerufen, um die vom Nutzer eingegebene TAN abzulegen. */ public static function submitPendingTan(string $token, string $tan): bool { if (!preg_match('/^[a-f0-9]{32}$/', $token)) { return false; } return (bool)@file_put_contents(self::tanDir() . '/' . $token . '.tan', $tan); } /** Wird vom POST-Endpoint aufgerufen, um die Konto-Auswahl abzulegen. */ public static function submitAccountSelection(string $token, array $ids): bool { if (!preg_match('/^[a-f0-9]{32}$/', $token)) { return false; } $ids = array_values(array_filter(array_map('strval', $ids), fn($s) => $s !== '')); return (bool)@file_put_contents( self::tanDir() . '/' . $token . '.accounts', json_encode($ids, JSON_UNESCAPED_UNICODE) ); } /** Polled bis zu $timeoutSec Sekunden auf die TAN-Datei. */ private static function awaitTan(string $token, int $timeoutSec): ?string { $file = self::tanDir() . '/' . $token . '.tan'; $start = time(); while (time() - $start < $timeoutSec) { if (file_exists($file)) { $tan = trim((string)@file_get_contents($file)); @unlink($file); return $tan !== '' ? $tan : null; } sleep(1); } return null; } /** Polled bis zu $timeoutSec Sekunden auf die Account-Auswahl-Datei. */ private static function awaitAccountSelection(string $token, int $timeoutSec): ?array { $file = self::tanDir() . '/' . $token . '.accounts'; $start = time(); while (time() - $start < $timeoutSec) { if (file_exists($file)) { $raw = (string)@file_get_contents($file); @unlink($file); $decoded = json_decode($raw, true); return is_array($decoded) ? array_values(array_map('strval', $decoded)) : null; } sleep(1); } return null; } private function createBatch(): int { Database::execute( 'INSERT INTO bank_import_batches (source_id, filename, row_count) VALUES (?, ?, 0)', [$this->sourceId, 'fints-' . date('Y-m-d')] ); return (int)Database::lastInsertId(); } private function updateBatchCount(int $batchId, int $count): void { Database::execute( 'UPDATE bank_import_batches SET row_count = ? WHERE id = ?', [$count, $batchId] ); } private function updateLastFetched(?float $balance): void { BankSource::updateLastFetched($this->sourceId, $balance); } private function externalIdExists(string $externalId): bool { $row = Database::fetchOne( 'SELECT id FROM bank_transactions WHERE external_id = ? LIMIT 1', [$externalId] ); return $row !== false && $row !== null; } private function insertRow(int $batchId, array $data): void { Database::execute( 'INSERT INTO bank_transactions (batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ $batchId, $data['buchungstag'], $data['wertstellung'], $data['vorgang'], $data['buchungstext'], $data['wert'], $data['raw_line'] ?? null, $data['external_id'], ] ); } }