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>
372 lines
13 KiB
PHP
372 lines
13 KiB
PHP
<?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]);
|
||
}
|
||
}
|