599 lines
22 KiB
PHP
599 lines
22 KiB
PHP
<?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\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;
|
||
}
|
||
|
||
try {
|
||
SsrfGuard::assertPublicHttpsUrl($url);
|
||
} catch (\Throwable $e) {
|
||
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$options = new FinTsOptions();
|
||
$options->url = $url;
|
||
$options->bankCode = $blz;
|
||
$options->productName = 'F399A6377D4D60E0A222D677A';
|
||
$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()));
|
||
}
|
||
|
||
$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() . ')');
|
||
|
||
$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()) {
|
||
if (!$this->handleStrongAuthentication($login, 'Login')) {
|
||
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;
|
||
$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++;
|
||
}
|
||
}
|
||
$log(' → ' . $accountNew . ' neue Buchungen.');
|
||
}
|
||
|
||
$this->updateBatchCount($batchId, $totalNew);
|
||
$this->updateLastFetched($balance);
|
||
|
||
$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 {
|
||
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'],
|
||
]
|
||
);
|
||
}
|
||
}
|