Steuerkram/src/Services/FinTsFetchService.php
2026-05-23 11:29:24 +02:00

541 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Steuerkram\Models\BankSource;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\GetStatementOfAccount;
use Fhp\BaseAction;
use Fhp\FinTs;
use Fhp\Model\StatementOfAccount\Transaction;
use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
/**
* FinTS-Leseabruf für Bankverbindungen mit type='fints'.
*
* Erwartet eine entschlüsselte bank_sources-Zeile mit:
* - blz, benutzerkennung, pin (verschlüsselt in DB, hier im Klartext erwartet)
* - fints_url (optional, sonst default_fints_url aus dem Provider-Join)
* - iban_filter (optional, nur dieses Konto abrufen)
*
* TAN-Pflicht: In dieser ersten Version werden Aktionen, die eine TAN erfordern,
* mit einer klaren Fehlermeldung abgebrochen. Die meisten Banken erlauben den
* Leseabruf via PIN ohne zusätzliche TAN.
*/
class FinTsFetchService
{
private array $source;
private int $sourceId;
private ?FinTs $fints = null;
/** @var callable */
private $log;
/** @var callable|null Wenn gesetzt, statt $this->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:<accountNumber>:<subAccount>". 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<int,mixed>|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'],
]
);
}
}