Nennt die Bank bei der TAN-Abfrage einen Startcode für den TAN-Generator, geht er im Fließtext leicht unter. StartcodeErkennung erkennt ihn über einen pro Bankverbindung konfigurierbaren Beispielsatz (Ziffernfolge = Platzhalter für den Code) und zeigt den Fund fett, zentriert und in eigener Zeile. BankFreitext löst dafür die bisherige Klartext-Aufbereitung aus FinTsUeberweisung::vopHinweis() heraus, damit TAN-Anfrage und VoP-Rückmeldung dieselbe HTML-zu-Klartext-Umwandlung nutzen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
978 lines
36 KiB
PHP
978 lines
36 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Fhp\Action\GetBalance;
|
||
use Fhp\Action\GetDepotAufstellung;
|
||
use Fhp\Action\GetSEPAAccounts;
|
||
use Fhp\Action\GetStatementOfAccount;
|
||
use Fhp\BaseAction;
|
||
use Fhp\FinTs;
|
||
use Fhp\Model\SEPAAccount;
|
||
use Fhp\Model\StatementOfAccount\Transaction;
|
||
use Fhp\Model\TanRequestChallengeImage;
|
||
use Fhp\Options\Credentials;
|
||
use Fhp\Options\FinTsOptions;
|
||
use Finanzmanager\Auth;
|
||
use Finanzmanager\Models\AbgleichRegel;
|
||
use Finanzmanager\Models\Bankabruf;
|
||
use Finanzmanager\Models\Bankinstitut;
|
||
use Finanzmanager\Models\Bankprotokoll;
|
||
use Finanzmanager\Models\Bankumsatz;
|
||
use Finanzmanager\Models\Bankzugang;
|
||
use Finanzmanager\Models\Einstellung;
|
||
use Finanzmanager\Models\Konto;
|
||
|
||
/**
|
||
* Der Umsatzabruf über FinTS.
|
||
*
|
||
* **Warum das über mehrere Requests läuft.** Fast jede Bank verlangt beim Login
|
||
* eine TAN – schon für den reinen Lesezugriff. Zwischen Anforderung und Eingabe
|
||
* liegt eine Interaktion des Nutzers, also mindestens ein weiterer Request. Die
|
||
* Bibliothek sieht dafür `FinTs::persist()` und das Serialisieren der laufenden
|
||
* Aktion vor; beides wird hier in der Sitzung abgelegt und beim nächsten Request
|
||
* wieder geladen. Ein Warteschleifen-Prozess, der die TAN abwartet, käme mit
|
||
* php-fpm nicht weit.
|
||
*
|
||
* **Was in der Sitzung liegt.** Der persistierte Dialog (er enthält keine PIN –
|
||
* die Bibliothek schließt sie ausdrücklich aus), die serialisierte Aktion und
|
||
* der Punkt, an dem weiterzumachen ist. Die PIN selbst wird bei jedem Request
|
||
* neu aus der Datenbank geholt und entschlüsselt.
|
||
*
|
||
* **Der Ablauf** ist eine kleine Zustandsmaschine:
|
||
*
|
||
* anmelden → konten → umsaetze (je Konto) → fertig
|
||
*
|
||
* Jeder Schritt kann eine TAN verlangen. Dann wird der Zustand gesichert und der
|
||
* Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle
|
||
* weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank
|
||
* – ein Abbruch mittendrin verliert also keine Arbeit.
|
||
*
|
||
* Im Modus `konten` endet der Lauf nach der Kontenabfrage. Das ist der dritte
|
||
* Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe
|
||
* Zustandsmaschine, dieselbe TAN-Behandlung, nur kürzer.
|
||
*
|
||
* Im Modus `depot` folgt auf die Anmeldung die Depotaufstellung (HKWPD) je
|
||
* Depot, das die Bank in den Kontostammdaten (UPD) führt. Ein Depot hat keine
|
||
* IBAN und fehlt deshalb in der Kontenabfrage (HKSPA). Der Modus läuft bewusst
|
||
* getrennt vom Umsatzabruf: andere Daten, anderer Rhythmus, eigene TAN.
|
||
*
|
||
* Ist `banking_automatisch_buchen` eingeschaltet, bucht `Bankabgleich` am Ende
|
||
* eines vollen Laufs, was ein Konto hat (siehe dort).
|
||
*/
|
||
class FinTsAbruf
|
||
{
|
||
/** Schlüssel des Abrufzustands in der Sitzung. */
|
||
public const SITZUNG = 'fints_abruf';
|
||
|
||
/** Produktkennung gegenüber der Bank. Ohne eigene Registrierung die von phpFinTS. */
|
||
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
|
||
private const PRODUKT_VERSION = '1.0';
|
||
|
||
/** @var list<string> */
|
||
private array $protokoll = [];
|
||
|
||
private FinTs $fints;
|
||
|
||
/** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */
|
||
private BankProtokollLogger $mitschnitt;
|
||
|
||
/** @param array<string,mixed> $zugang mit entschlüsselter PIN */
|
||
private function __construct(
|
||
private array $zugang,
|
||
private int $abrufId,
|
||
private array $zustand
|
||
) {
|
||
$this->mitschnitt = new BankProtokollLogger();
|
||
}
|
||
|
||
/**
|
||
* Startet einen Abruf.
|
||
*
|
||
* `$modus` entscheidet, wie weit er läuft: `voll` ruft die Umsätze ab,
|
||
* `konten` hält nach der Kontenabfrage an. Der kurze Lauf ist der dritte
|
||
* Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor
|
||
* Umsätze hereinkommen, die sonst ohne Konto dastünden. `depot` holt statt
|
||
* der Umsätze die Depotaufstellung.
|
||
*
|
||
* `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des Zugangs
|
||
* vor. Er liegt im Zustand, nicht in der Datenbank: Nach einer TAN-Runde
|
||
* läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum
|
||
* holen wie beim Start.
|
||
*
|
||
* @return array{status: string, meldung: string, protokoll: list<string>,
|
||
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
||
*/
|
||
public static function starten(
|
||
int $zugangId,
|
||
string $modus = 'voll',
|
||
bool $einrichtung = false,
|
||
?string $stichtag = null
|
||
): array {
|
||
$zugang = Bankzugang::zugangsdaten($zugangId);
|
||
if ($zugang === null) {
|
||
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht.', []);
|
||
}
|
||
|
||
foreach (['blz' => 'Bankleitzahl', 'fints_url' => 'FinTS-Adresse',
|
||
'benutzerkennung' => 'Benutzerkennung', 'pin' => 'PIN'] as $feld => $name) {
|
||
if (trim((string)$zugang[$feld]) === '') {
|
||
return self::ergebnis('fehler', 'Es fehlt: ' . $name . '.', []);
|
||
}
|
||
}
|
||
|
||
$stichtag = trim((string)$stichtag);
|
||
if ($stichtag !== '' && Bankzugang::stichtagDatum($stichtag) === null) {
|
||
return self::ergebnis('fehler', 'Der Stichtag ist kein gültiges Datum.', []);
|
||
}
|
||
|
||
self::vergessen();
|
||
|
||
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
||
'schritt' => 'anmelden',
|
||
'modus' => in_array($modus, ['konten', 'depot'], true) ? $modus : 'voll',
|
||
'einrichtung' => $einrichtung,
|
||
'stichtag' => $stichtag,
|
||
'konto_nummer' => 0,
|
||
'neu' => 0,
|
||
'uebersprungen' => 0,
|
||
'protokoll' => [],
|
||
]);
|
||
|
||
return $abruf->lauf(null);
|
||
}
|
||
|
||
/**
|
||
* Setzt einen Abruf fort, der auf eine TAN wartete.
|
||
*
|
||
* @return array{status: string, meldung: string, protokoll: list<string>,
|
||
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
||
*/
|
||
public static function tanEinreichen(string $tan): array
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
if ($zustand === null) {
|
||
return self::ergebnis('fehler', 'Es wartet kein Abruf auf eine TAN.', []);
|
||
}
|
||
|
||
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
|
||
if ($zugang === null) {
|
||
self::vergessen();
|
||
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht mehr.', []);
|
||
}
|
||
|
||
$abruf = new self($zugang, (int)$zustand['abruf_id'], $zustand);
|
||
|
||
return $abruf->lauf(trim($tan));
|
||
}
|
||
|
||
/** Wartet gerade ein Abruf auf eine TAN? */
|
||
public static function wartetAufTan(): bool
|
||
{
|
||
return self::zustandLesen() !== null;
|
||
}
|
||
|
||
/** @return array<string,mixed>|null Die Angaben für die TAN-Maske. */
|
||
public static function offeneTanAnfrage(): ?array
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
|
||
return $zustand === null ? null : ($zustand['tan'] ?? null);
|
||
}
|
||
|
||
public static function abbrechen(): void
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
if ($zustand !== null) {
|
||
Bankabruf::abschliessen(
|
||
(int)$zustand['abruf_id'],
|
||
'abgebrochen',
|
||
(int)$zustand['neu'],
|
||
(int)$zustand['uebersprungen'],
|
||
'Vom Nutzer abgebrochen.',
|
||
$zustand['protokoll'] ?? []
|
||
);
|
||
}
|
||
|
||
self::vergessen();
|
||
}
|
||
|
||
// ── Ablauf ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Ein Durchlauf: Verbindung herstellen (oder wiederherstellen), gegebenenfalls
|
||
* die TAN einreichen und dann so weit laufen, wie es ohne Rückfrage geht.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function lauf(?string $tan): array
|
||
{
|
||
$ergebnis = $this->durchlauf($tan);
|
||
|
||
$this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']);
|
||
try {
|
||
Bankprotokoll::speichern(
|
||
(int)$this->zugang['id'],
|
||
'abruf',
|
||
match ($ergebnis['status']) {
|
||
'erfolg' => 'erfolg',
|
||
'tan' => 'offen',
|
||
default => 'fehler',
|
||
},
|
||
(string)$ergebnis['meldung'],
|
||
$this->mitschnitt->text()
|
||
);
|
||
} catch (\Throwable $e) {
|
||
// Ein Fehler im Protokoll darf den Abruf nicht verdecken.
|
||
DebugLog::fehler($e);
|
||
}
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function durchlauf(?string $tan): array
|
||
{
|
||
$this->protokoll = $this->zustand['protokoll'] ?? [];
|
||
|
||
try {
|
||
$this->fints = $this->verbindung();
|
||
} catch (\Throwable $e) {
|
||
return $this->beenden('fehler', 'Verbindung fehlgeschlagen: ' . $e->getMessage());
|
||
}
|
||
|
||
// Eine wartende Aktion zuerst abschließen – erst danach geht es im
|
||
// Ablauf weiter.
|
||
if ($tan !== null && isset($this->zustand['aktion'])) {
|
||
$antwort = $this->tanAbschliessen($tan);
|
||
if ($antwort !== null) {
|
||
return $antwort;
|
||
}
|
||
}
|
||
|
||
try {
|
||
return $this->abarbeiten();
|
||
} catch (\Throwable $e) {
|
||
DebugLog::fehler($e);
|
||
return $this->beenden('fehler', $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function abarbeiten(): array
|
||
{
|
||
if ($this->zustand['schritt'] === 'anmelden') {
|
||
$anmeldung = $this->fints->login();
|
||
$wartet = $this->pruefeTan($anmeldung, 'Anmeldung', 'anmelden');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->notiere('Angemeldet bei ' . ($this->zugang['bankname'] ?: $this->zugang['name']) . '.');
|
||
$this->zustand['schritt'] = $this->naechsterSchrittNachAnmeldung();
|
||
}
|
||
|
||
if ($this->zustand['schritt'] === 'depot') {
|
||
return $this->depotsAbrufen();
|
||
}
|
||
|
||
if ($this->zustand['schritt'] === 'konten') {
|
||
$aktion = GetSEPAAccounts::create();
|
||
$this->fints->execute($aktion);
|
||
|
||
$wartet = $this->pruefeTan($aktion, 'Kontenabfrage', 'konten');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->merkeKonten($aktion->getAccounts());
|
||
$this->zustand['schritt'] = 'umsaetze';
|
||
}
|
||
|
||
// Der kurze Lauf endet hier: Die Konten stehen fest, zugeordnet werden
|
||
// sie auf der Seite, von der aus er gestartet wurde.
|
||
if (($this->zustand['modus'] ?? 'voll') === 'konten') {
|
||
return $this->beenden('erfolg', sprintf(
|
||
'%d Konto/Konten bei der Bank gefunden.',
|
||
count(Bankzugang::konten((int)$this->zugang['id']))
|
||
));
|
||
}
|
||
|
||
return $this->umsaetzeAbrufen();
|
||
}
|
||
|
||
/**
|
||
* Läuft die Konten des Zugangs durch. Der Index steht im Zustand: Verlangt
|
||
* ein Konto eine TAN, geht es danach genau dort weiter.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function umsaetzeAbrufen(): array
|
||
{
|
||
$konten = $this->abzurufendeKonten();
|
||
[$von, $beschreibung] = Bankzugang::abrufBeginn(
|
||
$this->zugang,
|
||
(string)($this->zustand['stichtag'] ?? '')
|
||
);
|
||
|
||
if ($this->zustand['konto_nummer'] === 0) {
|
||
$this->notiere('Zeitraum: ' . $beschreibung . ' (ab ' . $von->format('d.m.Y') . ').');
|
||
}
|
||
|
||
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
|
||
|
||
for ($nummer = (int)$this->zustand['konto_nummer']; $nummer < count($konten); $nummer++) {
|
||
$this->zustand['konto_nummer'] = $nummer;
|
||
$bankkonto = $konten[$nummer];
|
||
|
||
$sepa = $this->sepaKonto($bankkonto);
|
||
if ($sepa === null) {
|
||
$this->notiere('Übersprungen: ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer'])
|
||
. ' – die Bank führt dieses Konto nicht mehr.');
|
||
continue;
|
||
}
|
||
|
||
$aktion = GetStatementOfAccount::create(
|
||
$sepa,
|
||
\DateTime::createFromImmutable($von),
|
||
new \DateTime('today'),
|
||
false,
|
||
true
|
||
);
|
||
$this->fints->execute($aktion);
|
||
|
||
$wartet = $this->pruefeTan($aktion, 'Umsätze ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer']), 'umsaetze');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->schreibeUmsaetze($bankkonto, $aktion->getStatement()->getStatements(), $regeln);
|
||
$this->saldoHolen($sepa, (int)$bankkonto['id']);
|
||
}
|
||
|
||
Bankzugang::merkeAbruf((int)$this->zugang['id']);
|
||
|
||
$meldung = sprintf(
|
||
'%d neue Umsätze, %d bereits bekannt.',
|
||
$this->zustand['neu'],
|
||
$this->zustand['uebersprungen']
|
||
);
|
||
|
||
// Erst am Ende, nicht je Konto: So sieht der Lauf alle Umsätze auf
|
||
// einmal und kann jeden mit der Buchung verbinden, die zu ihm passt.
|
||
$gebucht = Bankabgleich::automatisch();
|
||
if ($gebucht !== null) {
|
||
$bericht = Bankabgleich::bericht($gebucht);
|
||
$this->notiere($bericht);
|
||
$meldung .= ' ' . $bericht;
|
||
}
|
||
|
||
return $this->beenden('erfolg', $meldung);
|
||
}
|
||
|
||
/**
|
||
* Schreibt die Umsätze eines Kontos weg.
|
||
*
|
||
* @param array<string,mixed> $bankkonto
|
||
* @param list<mixed> $auszuege
|
||
* @param list<array<string,mixed>> $regeln
|
||
*/
|
||
private function schreibeUmsaetze(array $bankkonto, array $auszuege, array $regeln): void
|
||
{
|
||
$neu = 0;
|
||
$alt = 0;
|
||
|
||
foreach ($auszuege as $auszug) {
|
||
foreach ($auszug->getTransactions() as $umsatz) {
|
||
/** @var Transaction $umsatz */
|
||
$buchungstag = $umsatz->getBookingDate() ?: $umsatz->getValutaDate() ?: new \DateTime();
|
||
$valuta = $umsatz->getValutaDate() ?: $buchungstag;
|
||
|
||
// Die Bibliothek liefert den Betrag ohne Vorzeichen; die Richtung
|
||
// steht in getCreditDebit().
|
||
$betrag = round(
|
||
(float)$umsatz->getAmount() * ($umsatz->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1),
|
||
2
|
||
);
|
||
|
||
$zweck = trim($umsatz->getMainDescription());
|
||
$empfaenger = trim($umsatz->getName());
|
||
|
||
// Die Kontonummer der Gegenseite – bei CAMT ihre IBAN, bei
|
||
// MT940 die alte Kontonummer; die Bankleitzahl bzw. BIC daneben
|
||
// löst sich sofort gegen das Institutsverzeichnis auf, statt bei
|
||
// jeder Anzeige im Kontoblatt neu nachzuschlagen.
|
||
$gegenIban = trim($umsatz->getAccountNumber());
|
||
$gegenBankname = (string)(
|
||
Bankinstitut::findeNachBicOderBlz($umsatz->getBankCode())['institut'] ?? ''
|
||
);
|
||
|
||
$externId = Bankumsatz::externId(
|
||
(int)$bankkonto['id'],
|
||
$buchungstag->format('Y-m-d'),
|
||
$betrag,
|
||
$zweck,
|
||
$empfaenger
|
||
);
|
||
|
||
$id = Bankumsatz::anlegen([
|
||
'zugang_id' => (int)$this->zugang['id'],
|
||
'konto_id' => $bankkonto['konto_id'],
|
||
'bank_konto_id' => (int)$bankkonto['id'],
|
||
'extern_id' => $externId,
|
||
'buchungstag' => $buchungstag->format('Y-m-d'),
|
||
'wertstellung' => $valuta->format('Y-m-d'),
|
||
'betrag' => $betrag,
|
||
'empfaenger' => $empfaenger,
|
||
'verwendungszweck' => $zweck,
|
||
'gegen_iban' => $gegenIban,
|
||
'gegen_bankname' => $gegenBankname,
|
||
'buchungsart' => trim($umsatz->getBookingText()),
|
||
'roh' => json_encode([
|
||
'iban' => $bankkonto['iban'],
|
||
'eref' => $umsatz->getEndToEndID(),
|
||
'code' => $umsatz->getBookingCode(),
|
||
'name' => $empfaenger,
|
||
'zweck' => $zweck,
|
||
], JSON_UNESCAPED_UNICODE),
|
||
]);
|
||
|
||
if ($id === null) {
|
||
$alt++;
|
||
continue;
|
||
}
|
||
|
||
$neu++;
|
||
|
||
// Die Regel zählt ihren Treffer schon beim Abruf: Die Liste der
|
||
// Regeln zeigt damit, welche im Alltag überhaupt greifen.
|
||
if ($regeln !== []) {
|
||
$regel = AbgleichRegel::passendeRegel(
|
||
['empfaenger' => $empfaenger, 'verwendungszweck' => $zweck, 'betrag' => $betrag],
|
||
$regeln
|
||
);
|
||
if ($regel !== null) {
|
||
AbgleichRegel::zaehleTreffer((int)$regel['id']);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->zustand['neu'] += $neu;
|
||
$this->zustand['uebersprungen'] += $alt;
|
||
|
||
$this->notiere(sprintf(
|
||
'%s: %d neu, %d bereits bekannt.',
|
||
$bankkonto['iban'] ?: $bankkonto['kontonummer'],
|
||
$neu,
|
||
$alt
|
||
));
|
||
}
|
||
|
||
private function saldoHolen(SEPAAccount $sepa, int $bankKontoId): void
|
||
{
|
||
try {
|
||
$aktion = GetBalance::create($sepa);
|
||
$this->fints->execute($aktion);
|
||
|
||
// Für den Saldo wird keine TAN erfragt: Er ist eine Zugabe, und eine
|
||
// zweite TAN-Runde nur für eine Zahl wäre dem Nutzer nicht zu erklären.
|
||
if ($aktion->needsTan()) {
|
||
$this->notiere(' Saldo übersprungen – die Bank verlangt dafür eine eigene TAN.');
|
||
return;
|
||
}
|
||
|
||
$saldo = null;
|
||
foreach ($aktion->getBalances() as $stand) {
|
||
$saldo = ($saldo ?? 0.0) + $stand->getGebuchterSaldo()->getAmount();
|
||
}
|
||
|
||
if ($saldo !== null) {
|
||
Bankzugang::merkeSaldo($bankKontoId, round($saldo, 2));
|
||
$this->notiere(' Kontostand: ' . number_format($saldo, 2, ',', '.') . ' €');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$this->notiere(' Kontostand nicht abrufbar: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ── Depot ────────────────────────────────────────────────────────────────
|
||
|
||
private function naechsterSchrittNachAnmeldung(): string
|
||
{
|
||
return ($this->zustand['modus'] ?? 'voll') === 'depot' ? 'depot' : 'konten';
|
||
}
|
||
|
||
/**
|
||
* Holt die Depotaufstellung jedes Depots, das die Bank in den
|
||
* Kontostammdaten führt. Der Index steht im Zustand – verlangt ein Depot
|
||
* eine TAN, geht es danach genau dort weiter.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function depotsAbrufen(): array
|
||
{
|
||
if (!isset($this->zustand['depots'])) {
|
||
$this->zustand['depots'] = $this->depotkonten();
|
||
$this->zustand['depot_nummer'] = 0;
|
||
$this->zustand['depot_geholt'] = 0;
|
||
}
|
||
|
||
$depots = $this->zustand['depots'];
|
||
if ($depots === []) {
|
||
return $this->beenden('fehler', 'Die Bank meldet für diesen Zugang kein Depot mit Abrufmöglichkeit.');
|
||
}
|
||
|
||
for ($nummer = (int)$this->zustand['depot_nummer']; $nummer < count($depots); $nummer++) {
|
||
$this->zustand['depot_nummer'] = $nummer;
|
||
$depot = $depots[$nummer];
|
||
|
||
if ($this->eigenesDepot($depot) === null) {
|
||
$this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] . ' ist keinem Depot zugeordnet.');
|
||
continue;
|
||
}
|
||
|
||
$sepa = new SEPAAccount();
|
||
$sepa->setAccountNumber((string)$depot['kontonummer']);
|
||
$sepa->setSubAccount($depot['unterkonto'] !== '' ? (string)$depot['unterkonto'] : null);
|
||
$sepa->setBlz((string)$this->zugang['blz']);
|
||
|
||
$aktion = GetDepotAufstellung::create($sepa);
|
||
$this->fints->execute($aktion);
|
||
|
||
$wartet = $this->pruefeTan($aktion, 'Depot ' . $depot['kontonummer'], 'depot');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->schreibeDepot($depot, $aktion);
|
||
}
|
||
|
||
if ((int)$this->zustand['depot_geholt'] === 0) {
|
||
return $this->beenden(
|
||
'fehler',
|
||
'Das Depot ' . implode(' / ', array_column($depots, 'kontonummer'))
|
||
. ' ist noch keinem Depot im Finanzmanager zugeordnet. Bitte unter Online-Banking → Konten zuordnen zuordnen und dann erneut abrufen.'
|
||
);
|
||
}
|
||
|
||
return $this->beenden('erfolg', sprintf(
|
||
'Depotbestand abgerufen: %d Positionen in %d Depot(s).',
|
||
$this->zustand['neu'],
|
||
$this->zustand['depot_geholt']
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Die Depots aus den Kontostammdaten der Bank (UPD, `HIUPD`): Kontoart
|
||
* 30–39 oder ein erlaubter Geschäftsvorfall `HKWPD`. Die Bibliothek reicht die
|
||
* UPD nicht nach außen; sie steht nach der Anmeldung im FinTs-Objekt.
|
||
*
|
||
* @return list<array{kontonummer: string, unterkonto: string, name: string}>
|
||
*/
|
||
private function depotkonten(): array
|
||
{
|
||
$upd = (new \ReflectionProperty(FinTs::class, 'upd'))->getValue($this->fints);
|
||
if ($upd === null) {
|
||
$this->notiere('Die Bank hat keine Kontostammdaten (UPD) geliefert.');
|
||
return [];
|
||
}
|
||
|
||
$depots = [];
|
||
foreach ($upd->hiupd as $hiupd) {
|
||
$verbindung = $hiupd->getKontoverbindung();
|
||
$art = $hiupd->getKontoart();
|
||
$abrufbar = false;
|
||
foreach ($hiupd->getErlaubteGeschaeftsvorfaelle() as $erlaubt) {
|
||
$abrufbar = $abrufbar || $erlaubt->getGeschaeftsvorfall() === 'HKWPD';
|
||
}
|
||
|
||
$istDepot = ($art !== null && $art >= 30 && $art <= 39) || $abrufbar;
|
||
if ($verbindung === null || !$istDepot) {
|
||
continue;
|
||
}
|
||
|
||
$nummer = (string)$verbindung->getAccountNumber();
|
||
if (!$abrufbar) {
|
||
$this->notiere('Depot ' . $nummer . ' übersprungen – die Bank erlaubt dafür keine Depotaufstellung.');
|
||
continue;
|
||
}
|
||
|
||
$depots[] = [
|
||
'kontonummer' => $nummer,
|
||
'unterkonto' => (string)$verbindung->unterkontomerkmal,
|
||
'name' => trim((string)$hiupd->getKontoproduktbezeichnung()),
|
||
];
|
||
$this->notiere('Depot bei der Bank: ' . $nummer . ' (' . trim((string)$hiupd->getName1()) . ').');
|
||
}
|
||
|
||
return $depots;
|
||
}
|
||
|
||
/**
|
||
* Das Depot im Finanzmanager, dem das Depot der Bank zugeordnet ist.
|
||
*
|
||
* @param array<string,string> $depot
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private function eigenesDepot(array $depot): ?array
|
||
{
|
||
$bankkonto = Bankzugang::findeOderLegeDepotAn((int)$this->zugang['id'], $depot['kontonummer'], $depot['name']);
|
||
$kontoId = (int)($bankkonto['konto_id'] ?? 0);
|
||
|
||
return $kontoId > 0 ? Konto::find($kontoId) : null;
|
||
}
|
||
|
||
/** @param array<string,string> $depot */
|
||
private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void
|
||
{
|
||
$konto = $this->eigenesDepot($depot);
|
||
if ($konto === null) {
|
||
return;
|
||
}
|
||
|
||
$ergebnis = Depotabgleich::uebernehmen((int)$konto['id'], $aktion->getStatement()->getHoldings());
|
||
|
||
$this->zustand['neu'] += $ergebnis['positionen'];
|
||
$this->zustand['depot_geholt'] = (int)($this->zustand['depot_geholt'] ?? 0) + 1;
|
||
$this->zustand['depot_nummer'] = (int)$this->zustand['depot_nummer'] + 1;
|
||
|
||
$this->notiere(sprintf(
|
||
'%s: %d Positionen, Depotwert laut Bank %s €, %d Kurse übernommen.',
|
||
$konto['name'],
|
||
$ergebnis['positionen'],
|
||
number_format($aktion->getDepotWert(), 2, ',', '.'),
|
||
$ergebnis['kurse']
|
||
));
|
||
}
|
||
|
||
// ── TAN ──────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Verlangt die Aktion eine TAN, wird der Zustand gesichert und der Aufrufer
|
||
* bekommt die Angaben für die Eingabemaske.
|
||
*
|
||
* @return array<string,mixed>|null null, wenn es ohne TAN weitergeht
|
||
*/
|
||
private function pruefeTan(BaseAction $aktion, string $bezeichnung, string $schritt): ?array
|
||
{
|
||
if (!$aktion->needsTan()) {
|
||
return null;
|
||
}
|
||
|
||
$verfahren = $this->fints->getSelectedTanMode();
|
||
$anfrage = $aktion->getTanRequest();
|
||
|
||
// Entkoppelte Verfahren (pushTAN-App): Die Freigabe passiert im Gerät des
|
||
// Nutzers, wir fragen nur nach. Der Nutzer drückt in der Maske auf
|
||
// „Freigabe prüfen“, statt eine Nummer abzutippen.
|
||
$entkoppelt = $verfahren !== null && $verfahren->isDecoupled();
|
||
|
||
$tan = [
|
||
'bezeichnung' => $bezeichnung,
|
||
'hinweis' => StartcodeErkennung::hervorheben(
|
||
$anfrage?->getChallenge() ?? '',
|
||
(string)($this->zugang['startcode_muster'] ?? '')
|
||
),
|
||
'medium' => $anfrage?->getTanMediumName() ?? '',
|
||
'entkoppelt' => $entkoppelt,
|
||
'bild_typ' => null,
|
||
'bild_daten' => null,
|
||
];
|
||
|
||
if (!$entkoppelt && $anfrage?->getChallengeHhdUc()) {
|
||
try {
|
||
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
|
||
$tan['bild_typ'] = $bild->getMimeType();
|
||
$tan['bild_daten'] = base64_encode($bild->getData());
|
||
} catch (\Throwable) {
|
||
// Kein photoTAN-Bild (etwa chipTAN-Flicker) – dann eben ohne.
|
||
}
|
||
}
|
||
|
||
$this->notiere('TAN erforderlich: ' . $bezeichnung . '.');
|
||
|
||
$this->zustand['schritt'] = $schritt;
|
||
$this->zustand['tan'] = $tan;
|
||
$this->zustand['aktion'] = serialize($aktion);
|
||
|
||
return $this->beenden('tan', 'Die Bank verlangt eine Freigabe.', $tan);
|
||
}
|
||
|
||
/**
|
||
* Reicht die TAN für die wartende Aktion nach.
|
||
*
|
||
* @return array<string,mixed>|null Fehlerergebnis, oder null bei Erfolg
|
||
*/
|
||
private function tanAbschliessen(string $tan): ?array
|
||
{
|
||
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
|
||
if (!$aktion instanceof BaseAction) {
|
||
return $this->beenden('fehler', 'Der wartende Vorgang ist nicht mehr lesbar. Bitte neu abrufen.');
|
||
}
|
||
|
||
$entkoppelt = ($this->zustand['tan']['entkoppelt'] ?? false) === true;
|
||
|
||
try {
|
||
if ($entkoppelt) {
|
||
// Kein Warten im Request: Ein Klick, eine Nachfrage. Steht die
|
||
// Freigabe noch aus, bleibt die Maske stehen.
|
||
if (!$this->fints->checkDecoupledSubmission($aktion)) {
|
||
$this->zustand['aktion'] = serialize($aktion);
|
||
return $this->beenden(
|
||
'tan',
|
||
'Noch keine Freigabe eingegangen. Im Banking-Gerät bestätigen und erneut prüfen.',
|
||
$this->zustand['tan']
|
||
);
|
||
}
|
||
} else {
|
||
if ($tan === '') {
|
||
return $this->beenden('tan', 'Bitte die TAN eingeben.', $this->zustand['tan']);
|
||
}
|
||
$this->fints->submitTan($aktion, $tan);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
return $this->beenden('fehler', 'Die Freigabe wurde abgelehnt: ' . $e->getMessage());
|
||
}
|
||
|
||
$this->notiere('Freigabe angenommen.');
|
||
unset($this->zustand['aktion'], $this->zustand['tan']);
|
||
|
||
// Die Aktion war der letzte Schritt vor dem Weitermachen: Ihr Ergebnis
|
||
// wird gebraucht, wenn es die Kontenliste war.
|
||
if ($this->zustand['schritt'] === 'konten' && $aktion instanceof GetSEPAAccounts) {
|
||
$this->merkeKonten($aktion->getAccounts());
|
||
$this->zustand['schritt'] = 'umsaetze';
|
||
} elseif ($this->zustand['schritt'] === 'anmelden') {
|
||
$this->zustand['schritt'] = $this->naechsterSchrittNachAnmeldung();
|
||
} elseif ($this->zustand['schritt'] === 'depot' && $aktion instanceof GetDepotAufstellung) {
|
||
$depot = $this->zustand['depots'][(int)$this->zustand['depot_nummer']] ?? null;
|
||
if ($depot !== null) {
|
||
$this->schreibeDepot($depot, $aktion);
|
||
}
|
||
} elseif ($this->zustand['schritt'] === 'umsaetze' && $aktion instanceof GetStatementOfAccount) {
|
||
$konten = $this->abzurufendeKonten();
|
||
$konto = $konten[(int)$this->zustand['konto_nummer']] ?? null;
|
||
if ($konto !== null) {
|
||
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
|
||
$this->schreibeUmsaetze($konto, $aktion->getStatement()->getStatements(), $regeln);
|
||
$this->zustand['konto_nummer'] = (int)$this->zustand['konto_nummer'] + 1;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Verbindung und Zustand ───────────────────────────────────────────────
|
||
|
||
private function verbindung(): FinTs
|
||
{
|
||
$optionen = new FinTsOptions();
|
||
$optionen->url = (string)$this->zugang['fints_url'];
|
||
$optionen->bankCode = (string)$this->zugang['blz'];
|
||
$optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT;
|
||
$optionen->productVersion = self::PRODUKT_VERSION;
|
||
|
||
$zugangsdaten = Credentials::create(
|
||
(string)$this->zugang['benutzerkennung'],
|
||
(string)$this->zugang['pin']
|
||
);
|
||
|
||
$fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null);
|
||
$fints->setLogger($this->mitschnitt);
|
||
|
||
// Beim ersten Lauf steht das Verfahren noch nicht im Dialog – es kommt
|
||
// aus der Einstellung des Zugangs (siehe `Services\FinTsVerfahren`).
|
||
if (!isset($this->zustand['dialog'])) {
|
||
FinTsVerfahren::anwenden($fints, $this->zugang, fn(string $zeile) => $this->notiere($zeile));
|
||
}
|
||
|
||
return $fints;
|
||
}
|
||
|
||
/**
|
||
* Die Konten dieses Zugangs, die abgerufen werden sollen.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function abzurufendeKonten(): array
|
||
{
|
||
return array_values(array_filter(
|
||
Bankzugang::konten((int)$this->zugang['id']),
|
||
static fn(array $konto): bool => (int)$konto['abrufen'] === 1
|
||
));
|
||
}
|
||
|
||
/** @param list<SEPAAccount> $konten */
|
||
private function merkeKonten(array $konten): void
|
||
{
|
||
$this->zustand['sepa'] = [];
|
||
|
||
foreach ($konten as $konto) {
|
||
$eintrag = Bankzugang::findeOderLegeKontoAn(
|
||
(int)$this->zugang['id'],
|
||
(string)$konto->getIban(),
|
||
(string)$konto->getAccountNumber(),
|
||
(string)$konto->getIban() ?: (string)$konto->getAccountNumber()
|
||
);
|
||
|
||
// Die SEPAAccount-Objekte werden für den nächsten Request gebraucht,
|
||
// wenn eine TAN dazwischenkommt – sie sind schlichte Wertobjekte und
|
||
// lassen sich gefahrlos serialisieren.
|
||
$this->zustand['sepa'][self::kennung($eintrag)] = serialize($konto);
|
||
}
|
||
|
||
$this->notiere(count($konten) . ' Konto/Konten bei der Bank gefunden.');
|
||
|
||
// Depots haben keine IBAN und stehen nicht in dieser Liste, sondern in
|
||
// den Kontostammdaten. Sie werden hier gleich mit vermerkt, damit sie
|
||
// sich zuordnen lassen – ohne zusätzliche TAN.
|
||
try {
|
||
$this->merkeDepots();
|
||
} catch (\Throwable $e) {
|
||
$this->notiere('Depots nicht auslesbar: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
private function merkeDepots(): void
|
||
{
|
||
foreach ($this->depotkonten() as $depot) {
|
||
Bankzugang::findeOderLegeDepotAn(
|
||
(int)$this->zugang['id'],
|
||
$depot['kontonummer'],
|
||
$depot['name'] !== '' ? $depot['name'] : 'Depot'
|
||
);
|
||
}
|
||
}
|
||
|
||
/** @param array<string,mixed> $bankkonto */
|
||
private function sepaKonto(array $bankkonto): ?SEPAAccount
|
||
{
|
||
$roh = $this->zustand['sepa'][self::kennung($bankkonto)] ?? null;
|
||
if (!is_string($roh)) {
|
||
return null;
|
||
}
|
||
|
||
$konto = unserialize($roh, ['allowed_classes' => [SEPAAccount::class]]);
|
||
|
||
return $konto instanceof SEPAAccount ? $konto : null;
|
||
}
|
||
|
||
/** @param array<string,mixed> $konto */
|
||
private static function kennung(array $konto): string
|
||
{
|
||
return strtoupper((string)($konto['iban'] ?? '')) . '|' . (string)($konto['kontonummer'] ?? '');
|
||
}
|
||
|
||
private function notiere(string $zeile): void
|
||
{
|
||
$this->protokoll[] = date('H:i:s') . ' ' . $zeile;
|
||
DebugLog::schreibe('fints', $zeile);
|
||
}
|
||
|
||
/**
|
||
* Beendet den Durchlauf: Protokoll wegschreiben und den Zustand entweder
|
||
* sichern (wenn eine TAN aussteht) oder vergessen.
|
||
*
|
||
* @param array<string,mixed>|null $tan
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function beenden(string $status, string $meldung, ?array $tan = null): array
|
||
{
|
||
$this->protokoll[] = date('H:i:s') . ' ' . $meldung;
|
||
$this->zustand['protokoll'] = $this->protokoll;
|
||
|
||
Bankabruf::abschliessen(
|
||
$this->abrufId,
|
||
$status === 'tan' ? 'tan_offen' : $status,
|
||
(int)$this->zustand['neu'],
|
||
(int)$this->zustand['uebersprungen'],
|
||
$meldung,
|
||
$this->protokoll
|
||
);
|
||
|
||
if ($status === 'tan') {
|
||
$this->zustand['dialog'] = $this->fints->persist();
|
||
$this->zustand['zugang_id'] = (int)$this->zugang['id'];
|
||
$this->zustand['abruf_id'] = $this->abrufId;
|
||
self::zustandSchreiben($this->zustand);
|
||
} else {
|
||
self::vergessen();
|
||
try {
|
||
$this->fints->close();
|
||
} catch (\Throwable) {
|
||
// Der Dialog ist ohnehin zu Ende; ein Fehler beim Abmelden
|
||
// ändert am Ergebnis nichts.
|
||
}
|
||
}
|
||
|
||
$ergebnis = self::ergebnis(
|
||
$status,
|
||
$meldung,
|
||
$this->protokoll,
|
||
(int)$this->zustand['neu'],
|
||
(int)$this->zustand['uebersprungen'],
|
||
$tan
|
||
);
|
||
|
||
// Wohin es danach geht, weiß nur der Abruf selbst: Nach einer TAN-Runde
|
||
// kennt der Aufrufer weder den Zugang noch den Modus.
|
||
$ergebnis['zugang_id'] = (int)$this->zugang['id'];
|
||
$ergebnis['modus'] = (string)($this->zustand['modus'] ?? 'voll');
|
||
$ergebnis['einrichtung'] = ($this->zustand['einrichtung'] ?? false) === true;
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $protokoll
|
||
* @param array<string,mixed>|null $tan
|
||
* @return array<string,mixed>
|
||
*/
|
||
private static function ergebnis(
|
||
string $status,
|
||
string $meldung,
|
||
array $protokoll,
|
||
int $neu = 0,
|
||
int $uebersprungen = 0,
|
||
?array $tan = null
|
||
): array {
|
||
$ergebnis = [
|
||
'status' => $status,
|
||
'meldung' => $meldung,
|
||
'protokoll' => $protokoll,
|
||
'neu' => $neu,
|
||
'uebersprungen' => $uebersprungen,
|
||
];
|
||
|
||
if ($tan !== null) {
|
||
$ergebnis['tan'] = $tan;
|
||
}
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
/** @return array<string,mixed>|null */
|
||
private static function zustandLesen(): ?array
|
||
{
|
||
Auth::startSession();
|
||
$zustand = $_SESSION[self::SITZUNG] ?? null;
|
||
|
||
return is_array($zustand) ? $zustand : null;
|
||
}
|
||
|
||
/** @param array<string,mixed> $zustand */
|
||
private static function zustandSchreiben(array $zustand): void
|
||
{
|
||
Auth::startSession();
|
||
$_SESSION[self::SITZUNG] = $zustand;
|
||
}
|
||
|
||
private static function vergessen(): void
|
||
{
|
||
Auth::startSession();
|
||
unset($_SESSION[self::SITZUNG]);
|
||
}
|
||
}
|