Finanzmanager/src/Services/FinTsUeberweisung.php
fds2610-bot 4bd7ffed1b Online-Banking: TAN-Verfahren bei der Bank abfragen und wählen
Bisher entschied das erste Verfahren, das die Bank meldet – wer chipTAN
nutzt, kam über eine App-Freigabe nicht weiter. Die Wahl steht jetzt auf
einer eigenen Seite am Zugang.

Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, und
getTanModes() kommt ohne Anmeldung aus, verlangt also selbst keine TAN.

Behoben: Das Speichern des Zugangsformulars setzte Verfahren und
TAN-Medium zurück, weil beide Felder darin nicht vorkommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:07:02 +02:00

372 lines
13 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 Finanzmanager\Services;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\SendSEPATransfer;
use Fhp\BaseAction;
use Fhp\FinTs;
use Fhp\Model\SEPAAccount;
use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Finanzmanager\Auth;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
/**
* Sendet einen Zahlungsauftrag über FinTS ab.
*
* Aufgebaut wie `FinTsAbruf`, nur kürzer: anmelden, Konto suchen, Überweisung
* einreichen. Eine TAN ist hier die Regel und nicht die Ausnahme – für
* Überweisungen verlangt sie jede Bank. Der Dialog wird deshalb genauso über
* die Sitzung fortgesetzt.
*
* **Erst die Bank, dann die Buchung.** Die Buchung auf dem Auftragskonto
* entsteht erst, wenn die Bank den Auftrag angenommen hat. Vorher wäre sie eine
* Behauptung – und eine, die man nach einer abgelehnten TAN wieder einsammeln
* müsste.
*/
class FinTsUeberweisung
{
public const SITZUNG = 'fints_ueberweisung';
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
private const PRODUKT_VERSION = '1.0';
/**
* @param array<string,mixed> $zugang mit entschlüsselter PIN
* @param array<string,mixed> $zustand
*/
private function __construct(
private array $zugang,
private int $auftragId,
private array $zustand
) {
}
/**
* Startet das Absenden.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
*/
public static function senden(int $auftragId): array
{
$auftrag = Zahlungsauftrag::find($auftragId);
if ($auftrag === null) {
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht.'];
}
if ($auftrag['status'] === 'gesendet') {
return ['status' => 'fehler', 'meldung' => 'Dieser Auftrag wurde bereits gesendet.'];
}
$zugangId = (int)($auftrag['zugang_id'] ?? 0);
if ($zugangId === 0) {
return [
'status' => 'fehler',
'meldung' => 'Dem Auftrag ist keine Bankverbindung zugeordnet.',
];
}
$zugang = Bankzugang::zugangsdaten($zugangId);
if ($zugang === null) {
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
}
self::vergessen();
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
}
/**
* Setzt nach der TAN-Eingabe fort.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
*/
public static function tanEinreichen(string $tan): array
{
$zustand = self::zustandLesen();
if ($zustand === null) {
return ['status' => 'fehler', 'meldung' => 'Es wartet keine Überweisung auf eine Freigabe.'];
}
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
if ($zugang === null) {
self::vergessen();
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht mehr.'];
}
return (new self($zugang, (int)$zustand['auftrag_id'], $zustand))->lauf(trim($tan));
}
public static function wartetAufTan(): bool
{
return self::zustandLesen() !== null;
}
/** @return array<string,mixed>|null */
public static function offeneTanAnfrage(): ?array
{
$zustand = self::zustandLesen();
return $zustand === null ? null : ($zustand['tan'] ?? null);
}
public static function abbrechen(): void
{
self::vergessen();
}
// ── Ablauf ───────────────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function lauf(?string $tan): array
{
$auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag === null) {
self::vergessen();
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht mehr.'];
}
try {
$fints = $this->verbindung();
} catch (\Throwable $e) {
return $this->fehler('Verbindung fehlgeschlagen: ' . $e->getMessage());
}
try {
// Eine wartende Aktion zuerst abschließen.
if ($tan !== null && isset($this->zustand['aktion'])) {
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
if (!$aktion instanceof BaseAction) {
return $this->fehler('Der wartende Vorgang ist nicht mehr lesbar.');
}
if ($tan === '') {
return $this->tanNoetig($fints, $aktion, 'Überweisung', $this->zustand['schritt']);
}
$fints->submitTan($aktion, $tan);
if ($this->zustand['schritt'] === 'senden') {
return $this->angenommen($fints);
}
$this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden';
unset($this->zustand['aktion'], $this->zustand['tan']);
}
if ($this->zustand['schritt'] === 'anmelden') {
$anmeldung = $fints->login();
if ($anmeldung->needsTan()) {
return $this->tanNoetig($fints, $anmeldung, 'Anmeldung', 'anmelden');
}
$this->zustand['schritt'] = 'konten';
}
if ($this->zustand['schritt'] === 'konten' || !isset($this->zustand['sepa'])) {
$konten = GetSEPAAccounts::create();
$fints->execute($konten);
if ($konten->needsTan()) {
return $this->tanNoetig($fints, $konten, 'Kontenabfrage', 'konten');
}
$sepa = $this->passendesKonto($konten->getAccounts(), (int)$auftrag['konto_id']);
if ($sepa === null) {
return $this->fehler(
'Die Bank führt kein Konto mit der IBAN des Auftragskontos. '
. 'Bitte die IBAN im Konto prüfen.'
);
}
$this->zustand['sepa'] = serialize($sepa);
$this->zustand['schritt'] = 'senden';
}
if ($this->zustand['schritt'] === 'senden') {
$sepa = unserialize((string)$this->zustand['sepa'], ['allowed_classes' => [SEPAAccount::class]]);
if (!$sepa instanceof SEPAAccount) {
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
}
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa));
$aktion = SendSEPATransfer::create($sepa, $xml);
$fints->execute($aktion);
if ($aktion->needsTan()) {
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
}
return $this->angenommen($fints);
}
} catch (\Throwable $e) {
DebugLog::fehler($e);
return $this->fehler($e->getMessage());
}
return $this->fehler('Der Auftrag konnte nicht abgeschlossen werden.');
}
/**
* @param array<string,mixed> $auftrag
* @return array<string,mixed>
*/
private function auftragsdaten(array $auftrag, SEPAAccount $sepa): array
{
$konto = Konto::find((int)$auftrag['konto_id']);
return [
'absender_name' => (string)(Einstellung::get('mandant_name') ?: ($konto['name'] ?? 'Auftraggeber')),
'absender_iban' => (string)($sepa->getIban() ?: ($konto['iban'] ?? '')),
'absender_bic' => (string)($sepa->getBic() ?? ''),
'empfaenger_name' => (string)$auftrag['empfaenger'],
'empfaenger_iban' => (string)$auftrag['iban'],
'empfaenger_bic' => (string)$auftrag['bic'],
'betrag' => (float)$auftrag['betrag'],
'waehrung' => (string)$auftrag['waehrung'],
'verwendungszweck' => (string)$auftrag['verwendungszweck'],
'ausfuehrung_am' => $auftrag['ausfuehrung_am'],
];
}
/**
* @param list<SEPAAccount> $konten
*/
private function passendesKonto(array $konten, int $kontoId): ?SEPAAccount
{
$konto = Konto::find($kontoId);
$iban = strtoupper(str_replace(' ', '', (string)($konto['iban'] ?? '')));
foreach ($konten as $kandidat) {
if ($iban !== '' && strtoupper((string)$kandidat->getIban()) === $iban) {
return $kandidat;
}
}
// Ohne IBAN am Konto: Hat die Bank nur ein Konto, ist es eindeutig.
return $iban === '' && count($konten) === 1 ? $konten[0] : null;
}
/** @return array<string,mixed> */
private function angenommen(FinTs $fints): array
{
Zahlungsauftrag::alsGesendetVermerken($this->auftragId, 'Von der Bank angenommen.');
self::vergessen();
try {
$fints->close();
} catch (\Throwable) {
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
}
return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.'];
}
/** @return array<string,mixed> */
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
{
$verfahren = $fints->getSelectedTanMode();
$anfrage = $aktion->getTanRequest();
$tan = [
'bezeichnung' => $bezeichnung,
'hinweis' => $anfrage?->getChallenge() ?? '',
'medium' => $anfrage?->getTanMediumName() ?? '',
'entkoppelt' => $verfahren !== null && $verfahren->isDecoupled(),
'bild_typ' => null,
'bild_daten' => null,
];
if (!$tan['entkoppelt'] && $anfrage?->getChallengeHhdUc()) {
try {
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
$tan['bild_typ'] = $bild->getMimeType();
$tan['bild_daten'] = base64_encode($bild->getData());
} catch (\Throwable) {
// Kein Bild – dann eben ohne.
}
}
self::zustandSchreiben([
'schritt' => $schritt,
'aktion' => serialize($aktion),
'dialog' => $fints->persist(),
'sepa' => $this->zustand['sepa'] ?? null,
'tan' => $tan,
'zugang_id' => (int)$this->zugang['id'],
'auftrag_id' => $this->auftragId,
]);
return [
'status' => 'tan',
'meldung' => 'Die Bank verlangt eine Freigabe.',
'tan' => $tan,
];
}
/** @return array{status: string, meldung: string} */
private function fehler(string $meldung): array
{
Zahlungsauftrag::alsFehlerVermerken($this->auftragId, $meldung);
self::vergessen();
return ['status' => 'fehler', 'meldung' => $meldung];
}
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;
$fints = FinTs::new(
$optionen,
Credentials::create(
(string)$this->zugang['benutzerkennung'],
(string)$this->zugang['pin']
),
$this->zustand['dialog'] ?? null
);
$fints->setLogger(new DebugLogAdapter());
// Dieselbe Wahl wie beim Umsatzabruf – eine Überweisung soll nicht
// plötzlich ein anderes Verfahren verlangen als der Abruf.
if (!isset($this->zustand['dialog'])) {
FinTsVerfahren::anwenden(
$fints,
$this->zugang,
static fn(string $zeile) => DebugLog::schreibe('fints', $zeile)
);
}
return $fints;
}
/** @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]);
}
}