Steuerkram/src/Services/FinTsFetchService.php

760 lines
27 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\GetBalance;
use Fhp\Action\GetSEPAAccounts;
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;
/**
* 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;
$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:<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(?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'],
]
);
}
}