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; $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; } $options = new FinTsOptions(); $options->url = $url; $options->bankCode = $blz; $options->productName = 'FSCash'; $options->productVersion = '1.0'; $credentials = Credentials::create($user, $pin); try { $this->fints = FinTs::new($options, $credentials); } catch (\Throwable $e) { $log('FEHLER bei Initialisierung: ' . $e->getMessage()); return; } $fints = $this->fints; $log('TAN-Verfahren werden ermittelt…'); try { $tanModes = $fints->getTanModes(); } catch (\Throwable $e) { $log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage()); return; } if (empty($tanModes)) { $log('FEHLER: Bank meldet keine TAN-Verfahren.'); return; } foreach ($tanModes as $idx => $mode) { $log(sprintf(' [%d] %s (ID %d)', $idx, $mode->getName(), $mode->getId())); } $selectedTanMode = reset($tanModes); $log('Auswahl: ' . $selectedTanMode->getName() . ' (erstes verfügbares Verfahren)'); $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()); } try { $fints->selectTanMode($selectedTanMode, $selectedTanMedium); } catch (\Throwable $e) { $log('FEHLER bei selectTanMode: ' . $e->getMessage()); return; } $log('Login wird versucht…'); try { $login = $fints->login(); if ($login->needsTan()) { $log('FEHLER: Bank verlangt eine TAN für den Login.'); $log('TAN-Eingabe ist in dieser Version noch nicht unterstützt.'); return; } } catch (\Throwable $e) { $log('FEHLER beim Login: ' . $e->getMessage()); return; } $log('Login OK.'); try { $getAccounts = GetSEPAAccounts::create(); $fints->execute($getAccounts); if ($getAccounts->needsTan()) { $log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.'); return; } $accounts = $getAccounts->getAccounts(); } catch (\Throwable $e) { $log('FEHLER bei Kontenabfrage: ' . $e->getMessage()); return; } $log(count($accounts) . ' SEPA-Konten verfügbar.'); $accounts = $this->applyAccountFilter($accounts); if ($accounts === null) { return; } if (empty($accounts)) { $log('Kein Konto entspricht der Auswahl – Abbruch.'); return; } $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.'); $batchId = $this->createBatch(); $log('Batch #' . $batchId . ' angelegt.'); $totalNew = 0; $totalSkip = 0; 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; } $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++; } } $log(' → ' . $accountNew . ' neue Buchungen.'); } $this->updateBatchCount($batchId, $totalNew); $this->updateLastFetched(); $log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip)); } /** * 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 { $this->fints->checkDecoupledSubmission($action); return true; } catch (\Throwable $e) { 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(): void { BankSource::updateLastFetched($this->sourceId); } 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'], ] ); } }