Finanzmanager/src/Models/Bankzugang.php
fds2610-bot da8e3784fa Fix #50: Zahlungsverkehr wählt die Bank automatisch zum Auftragskonto
Das Überweisungsformular fragt nur noch das Auftragskonto ab; die passende
Bankverbindung ermittelt Bankzugang::zugangJeKonto() aus der Kontenzuordnung.
Konten ohne konfigurierte Bank stehen in der Auswahl nicht mehr zur Verfügung.

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

573 lines
23 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\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Services\Crypto;
/**
* Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt
* werden.
*
* Es gibt ihn in zwei Ausführungen (`typ`): den **HBCI/FinTS**-Zugang mit
* Bankleitzahl, Adresse, Benutzerkennung und PIN, und den **PayPal**-Zugang mit
* den drei Angaben der Classic-NVP-API. Beide teilen sich Tabelle, Konten,
* Umsätze und Abrufprotokoll – nur der Weg zur Bank ist ein anderer.
*
* PIN, TAN-Medium, API-Kennwort und API-Signatur stehen verschlüsselt in der
* Datenbank (`Services\Crypto`) und werden ausschließlich über
* `zugangsdaten()` wieder herausgereicht – wer die Zeile über `find()` holt,
* bekommt sie im Geheimtext.
*/
class Bankzugang
{
/** Die Arten von Bankverbindung, Schlüssel → Beschriftung. */
public const TYPEN = [
'fints' => 'HBCI/FinTS',
'paypal' => 'PayPal',
];
/** Kontonummer der einen Kontozeile eines PayPal-Zugangs. */
public const PAYPAL_KENNUNG = 'PAYPAL';
/** Vorgaben für den Abrufzeitraum, Schlüssel → Beschriftung. */
public const ZEITRAEUME = [
'letzter_abruf' => 'Seit dem letzten Abruf',
'sieben_tage' => 'Letzte 7 Tage',
'dreissig_tage' => 'Letzte 30 Tage',
'jahr' => 'Letzte 365 Tage',
'stichtag' => 'Ab Stichtag',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT * FROM bank_zugaenge WHERE benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Der Zugang mit entschlüsselter PIN. Nur der Abrufdienst ruft das auf.
*
* @return array<string,mixed>|null
*/
public static function zugangsdaten(int $id): ?array
{
$zugang = self::find($id);
if ($zugang === null) {
return null;
}
$zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? ''));
$zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? ''));
$zugang['api_kennwort'] = Crypto::entschluessle((string)($zugang['api_kennwort'] ?? ''));
$zugang['api_signatur'] = Crypto::entschluessle((string)($zugang['api_signatur'] ?? ''));
return $zugang;
}
/**
* @param array<string,mixed> $daten
*/
public static function anlegen(array $daten): int
{
$felder = self::normalisiere($daten);
self::verlangeAdresse((string)$felder['typ'], (string)$felder['fints_url']);
Database::execute(
'INSERT INTO bank_zugaenge
(benutzer_id, typ, name, bankname, blz, fints_url, benutzerkennung, pin,
api_benutzer, api_kennwort, api_signatur, sandbox,
tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum,
abruf_stichtag, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $felder['typ'], $felder['name'], $felder['bankname'], $felder['blz'],
$felder['fints_url'], $felder['benutzerkennung'], $felder['pin'],
$felder['api_benutzer'], $felder['api_kennwort'], $felder['api_signatur'],
$felder['sandbox'],
$felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'],
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
]
);
return Database::lastInsertId();
}
/**
* @param array<string,mixed> $daten
*/
public static function speichern(int $id, array $daten): void
{
$bisher = self::find($id);
if ($bisher === null) {
throw new \RuntimeException('Den Bankzugang gibt es nicht.');
}
$felder = self::normalisiere($daten);
// Der Typ steht beim Anlegen fest und wird nicht mehr umgestellt: Ein
// FinTS-Zugang, der plötzlich PayPal wäre, hätte weder passende
// Zugangsdaten noch passende Konten.
$felder['typ'] = (string)$bisher['typ'];
self::verlangeAdresse($felder['typ'], (string)$felder['fints_url']);
// Ein leeres PIN-Feld heißt „unverändert lassen“, nicht „PIN löschen“ –
// sonst verlöre jedes Speichern des Formulars die Zugangsdaten.
// Für die beiden PayPal-Geheimnisse gilt dasselbe.
foreach (['pin', 'api_kennwort', 'api_signatur'] as $geheimnis) {
if ($felder[$geheimnis] === null) {
$felder[$geheimnis] = $bisher[$geheimnis];
}
}
// Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular,
// sondern wird eigens abgefragt. Ohne diesen Vorbehalt setzte jedes
// Speichern die Wahl zurück.
foreach (['tan_verfahren_id', 'tan_verfahren_name', 'tan_medium'] as $feld) {
if (!array_key_exists($feld, $daten)) {
$felder[$feld] = $bisher[$feld];
}
}
Database::execute(
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
benutzerkennung = ?, pin = ?, api_benutzer = ?, api_kennwort = ?,
api_signatur = ?, sandbox = ?, tan_verfahren_id = ?, tan_verfahren_name = ?,
tan_medium = ?, abrufzeitraum = ?, abruf_stichtag = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
$felder['benutzerkennung'], $felder['pin'], $felder['api_benutzer'],
$felder['api_kennwort'], $felder['api_signatur'], $felder['sandbox'],
$felder['tan_verfahren_id'],
$felder['tan_verfahren_name'], $felder['tan_medium'],
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
$id, Mandant::id(),
]
);
}
/**
* Ohne Adresse hat ein FinTS-Zugang niemanden, den er fragen könnte.
*
* Geprüft wird erst, wenn der Typ feststeht: Beim Bearbeiten stammt er aus
* der Datenbank und nicht aus dem Formular, und ein PayPal-Zugang führt
* überhaupt keine FinTS-Adresse.
*
* Das Formular darf sie leer lassen – dann schlägt der Controller sie im
* Bankleitzahlen-Verzeichnis nach. Findet auch das nichts, endet es hier,
* und zwar mit einer Meldung, die den Grund nennt.
*/
private static function verlangeAdresse(string $typ, string $url): void
{
if ($typ === 'fints' && trim($url) === '') {
throw new \InvalidArgumentException(
'Die FinTS-Adresse fehlt und steht auch nicht im Bankleitzahlen-Verzeichnis '
. '– sie muss von Hand eingetragen werden.'
);
}
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function merkeAbruf(int $id): void
{
Database::execute(
'UPDATE bank_zugaenge SET zuletzt_am = NOW() WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
public static function merkeTanVerfahren(
int $id,
?int $verfahren,
string $medium = '',
string $bezeichnung = ''
): void {
Database::execute(
'UPDATE bank_zugaenge
SET tan_verfahren_id = ?, tan_verfahren_name = ?, tan_medium = ?
WHERE id = ? AND benutzer_id = ?',
[
$verfahren,
mb_substr($bezeichnung, 0, 128),
Crypto::verschluessle($medium),
$id,
Mandant::id(),
]
);
}
/**
* Ab wann für diesen Zugang abgerufen wird.
*
* Ein **beim Abruf angegebener Stichtag** geht der gespeicherten Vorgabe vor
* und wird nirgends festgehalten: Er gilt für diesen einen Lauf – etwa um
* einmalig weiter zurückzugreifen, ohne die Einstellung des Zugangs zu
* ändern.
*
* @param array<string,mixed> $zugang
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
*/
public static function abrufBeginn(
array $zugang,
?string $stichtag = null,
?\DateTimeImmutable $heute = null
): array {
if (trim((string)$stichtag) !== '') {
return self::startdatum('stichtag', null, $heute, $stichtag);
}
return self::startdatum(
(string)($zugang['abrufzeitraum'] ?? ''),
($zugang['zuletzt_am'] ?? null) === null ? null : (string)$zugang['zuletzt_am'],
$heute,
($zugang['abruf_stichtag'] ?? null) === null ? null : (string)$zugang['abruf_stichtag']
);
}
/**
* Ab wann abgerufen wird. „Seit dem letzten Abruf“ nimmt den Tag des letzten
* Laufs – bewusst den ganzen Tag und nicht die Uhrzeit: Banken buchen
* nachträglich, und ein Umsatz, der schon da war, fällt ohnehin über seine
* Kennung heraus.
*
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
*/
public static function startdatum(
?string $zeitraum,
?string $zuletztAm,
?\DateTimeImmutable $heute = null,
?string $stichtag = null
): array {
$heute = $heute ?? new \DateTimeImmutable('today');
$zeitraum = array_key_exists((string)$zeitraum, self::ZEITRAEUME) ? (string)$zeitraum : 'letzter_abruf';
return match ($zeitraum) {
'sieben_tage' => [$heute->modify('-7 days'), 'letzte 7 Tage'],
'dreissig_tage' => [$heute->modify('-30 days'), 'letzte 30 Tage'],
'jahr' => [$heute->modify('-365 days'), 'letzte 365 Tage'],
// Ohne brauchbares Datum wäre „Ab Stichtag“ eine Angabe ohne Inhalt –
// dann gilt dieselbe Regel wie ohne Vorgabe.
'stichtag' => self::abStichtag($heute, $stichtag) ?? self::seitLetztemAbruf($heute, $zuletztAm),
default => self::seitLetztemAbruf($heute, $zuletztAm),
};
}
/**
* Ein Stichtag als Datum – oder null, wenn sich keines daraus lesen lässt.
*
* Angenommen werden das Format des Datumsfelds (`2026-08-13`) und die
* deutsche Schreibweise (`13.08.2026`); alles andere gilt als Fehleingabe
* statt als „irgendein Tag“.
*/
public static function stichtagDatum(?string $roh): ?\DateTimeImmutable
{
$roh = trim((string)$roh);
if ($roh === '') {
return null;
}
foreach (['Y-m-d', 'd.m.Y'] as $format) {
$datum = \DateTimeImmutable::createFromFormat('!' . $format, $roh);
$fehler = \DateTimeImmutable::getLastErrors();
// `createFromFormat` biegt Unsinn wie den 31.02. still zurecht – die
// Warnungen sind das Einzige, woran sich das erkennen lässt.
if ($datum !== false
&& ($fehler === false || ($fehler['warning_count'] === 0 && $fehler['error_count'] === 0))) {
return $datum;
}
}
return null;
}
/** @return array{0: \DateTimeImmutable, 1: string}|null */
private static function abStichtag(\DateTimeImmutable $heute, ?string $stichtag): ?array
{
$datum = self::stichtagDatum($stichtag);
if ($datum === null) {
return null;
}
// Aus der Zukunft liefert keine Bank etwas; ein verrutschter Stichtag
// soll den Abruf nicht ergebnislos machen.
if ($datum > $heute) {
return [$heute, 'ab Stichtag ' . $heute->format('d.m.Y') . ' (in der Zukunft, auf heute gesetzt)'];
}
return [$datum, 'ab Stichtag ' . $datum->format('d.m.Y')];
}
/** @return array{0: \DateTimeImmutable, 1: string} */
private static function seitLetztemAbruf(\DateTimeImmutable $heute, ?string $zuletztAm): array
{
if ($zuletztAm === null || $zuletztAm === '') {
return [$heute->modify('-365 days'), 'erster Abruf – die letzten 365 Tage'];
}
$start = new \DateTimeImmutable(substr($zuletztAm, 0, 10));
return [$start, 'seit dem letzten Abruf am ' . $start->format('d.m.Y')];
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Der Bankzugang braucht einen Namen.');
}
$typ = (string)($daten['typ'] ?? 'fints');
$typ = array_key_exists($typ, self::TYPEN) ? $typ : 'fints';
$url = trim((string)($daten['fints_url'] ?? ''));
if ($url !== '' && !str_starts_with($url, 'https://')) {
throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.');
}
// Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim
// Anlegen wird daraus schlicht eine leere PIN. Für die beiden
// PayPal-Geheimnisse gilt dieselbe Regel.
//
// Getrimmt wird, bevor verschlüsselt wird: Eine aus der Zwischenablage
// eingefügte PayPal-Signatur bringt gern ein Leerzeichen mit, und PayPal
// antwortet darauf mit „Security header is not valid“ – einer Meldung,
// die alles Mögliche vermuten lässt, nur nicht das.
$geheim = static function (mixed $wert): ?string {
$wert = trim((string)($wert ?? ''));
return $wert === '' ? null : Crypto::verschluessle($wert);
};
$pin = $geheim($daten['pin'] ?? '');
$verfahren = $daten['tan_verfahren_id'] ?? null;
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
$zeitraum = (string)($daten['abrufzeitraum'] ?? 'letzter_abruf');
$zeitraum = array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf';
// Ein leeres Datumsfeld ist kein Fehler; „Ab Stichtag“ ohne Datum schon –
// sonst stünde eine Vorgabe da, die nichts vorgibt.
$stichtag = self::stichtagDatum((string)($daten['abruf_stichtag'] ?? ''));
if ($stichtag === null && trim((string)($daten['abruf_stichtag'] ?? '')) !== '') {
throw new \InvalidArgumentException('Der Stichtag ist kein gültiges Datum.');
}
if ($stichtag === null && $zeitraum === 'stichtag') {
throw new \InvalidArgumentException('Für „Ab Stichtag“ fehlt das Datum.');
}
return [
'typ' => $typ,
'name' => mb_substr($name, 0, 128),
'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128),
'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '',
'fints_url' => mb_substr($url, 0, 255),
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
'pin' => $pin,
'api_benutzer' => mb_substr(trim((string)($daten['api_benutzer'] ?? '')), 0, 191),
'api_kennwort' => $geheim($daten['api_kennwort'] ?? ''),
'api_signatur' => $geheim($daten['api_signatur'] ?? ''),
'sandbox' => !empty($daten['sandbox']) ? 1 : 0,
'tan_verfahren_id' => $verfahren,
'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128),
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
'abrufzeitraum' => $zeitraum,
'abruf_stichtag' => $stichtag?->format('Y-m-d'),
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
];
}
// ── Zugeordnete Bankkonten ───────────────────────────────────────────────
/**
* Konto-ID → Zugang-ID, für jedes eigene Konto mit konfigurierter Bank.
*
* Grundlage für die automatische Bankauswahl im Zahlungsverkehr: Nur
* Konten mit einem Eintrag hier tauchen dort in der Auswahl auf, und die
* Bank steht damit fest, sobald das Konto gewählt ist. Nur aktive
* Zugänge zählen – ein deaktivierter kann ohnehin nicht senden.
*
* @return array<int,int>
*/
public static function zugangJeKonto(): array
{
$zeilen = Database::fetchAll(
'SELECT bk.konto_id, MIN(bk.zugang_id) AS zugang_id
FROM bank_konten bk
JOIN bank_zugaenge z ON z.id = bk.zugang_id
WHERE z.benutzer_id = ? AND z.aktiv = 1 AND bk.konto_id IS NOT NULL
GROUP BY bk.konto_id',
[Mandant::id()]
);
$zuordnung = [];
foreach ($zeilen as $zeile) {
$zuordnung[(int)$zeile['konto_id']] = (int)$zeile['zugang_id'];
}
return $zuordnung;
}
/** @return list<array<string,mixed>> */
public static function konten(int $zugangId): array
{
return Database::fetchAll(
'SELECT bk.*, k.name AS kontoname
FROM bank_konten bk
LEFT JOIN konten k ON k.id = bk.konto_id
WHERE bk.zugang_id = ?
ORDER BY bk.iban, bk.kontonummer',
[$zugangId]
);
}
/**
* Das eine Konto eines PayPal-Zugangs – angelegt, falls es noch keines gibt.
*
* PayPal führt je Konto genau ein Guthabenkonto; eine Kontenabfrage wie bei
* FinTS gibt es dort nicht und wäre auch nichts zu wählen. Statt der IBAN
* steht die feste Kennung `PAYPAL` in der Zeile: Sie hält den
* Fingerabdruck der Umsätze stabil und macht die UNIQUE-Bedingung über
* (Zugang, IBAN, Kontonummer) eindeutig.
*
* @return array<string,mixed>
*/
public static function paypalKonto(int $zugangId): array
{
return self::findeOderLegeKontoAn($zugangId, '', self::PAYPAL_KENNUNG, 'PayPal-Guthaben');
}
/**
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
* welches eigene Konto die Umsätze laufen.
*
* **Die Kontonummer entscheidet, nicht die IBAN.** Manche Banken liefern die
* IBAN nicht bei jedem Dialog mit – die Comdirect etwa nennt sie beim
* Kontenabruf, beim Umsatzabruf aber nicht. Suchte man über beide Felder
* zusammen, entstünde beim zweiten Abruf ein zweiter Eintrag fürs selbe
* Konto: einmal mit IBAN, einmal ohne. Die Folge wären doppelte Umsätze,
* denn beide Einträge haben eine eigene Zuordnung und einen eigenen
* Fingerabdruck.
*/
public static function findeOderLegeKontoAn(
int $zugangId,
string $iban,
string $kontonummer,
string $bezeichnung = ''
): array {
$iban = strtoupper(str_replace(' ', '', $iban));
$kontonummer = trim($kontonummer);
$zeile = Database::fetchOne(
'SELECT * FROM bank_konten
WHERE zugang_id = ?
AND (kontonummer <> \'\' AND kontonummer = ? OR iban <> \'\' AND iban = ?)
ORDER BY iban = ? DESC, id
LIMIT 1',
[$zugangId, $kontonummer, $iban, $iban]
);
if ($zeile !== false) {
// Liefert die Bank die IBAN erst jetzt, wird sie nachgetragen –
// sonst bliebe der Eintrag für immer ohne.
if ($iban !== '' && (string)$zeile['iban'] === '') {
Database::execute('UPDATE bank_konten SET iban = ? WHERE id = ?', [$iban, (int)$zeile['id']]);
$zeile['iban'] = $iban;
}
return $zeile;
}
// Ein Konto mit passender IBAN gibt es vielleicht schon in der eigenen
// Kontenliste – dann ist die Zuordnung offensichtlich und wird gleich
// mit angelegt.
$konto = $iban === '' ? null : Database::fetchOne(
'SELECT id FROM konten WHERE benutzer_id = ? AND iban = ? LIMIT 1',
[Mandant::id(), $iban]
);
Database::execute(
'INSERT INTO bank_konten (zugang_id, konto_id, iban, kontonummer, bezeichnung)
VALUES (?, ?, ?, ?, ?)',
[
$zugangId,
$konto === false || $konto === null ? null : (int)$konto['id'],
$iban,
mb_substr($kontonummer, 0, 34),
mb_substr($bezeichnung, 0, 128),
]
);
return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: [];
}
/**
* Ordnet ein Bankkonto einem eigenen Konto zu.
*
* Die schon abgerufenen **offenen** Umsätze ziehen mit: Wer die Zuordnung
* erst nach einem Abruf trifft, hätte sonst einen Stapel Umsätze ohne Konto,
* die sich nicht buchen lassen. Übernommene bleiben, wo sie sind – ihre
* Buchung steht bereits in einem Kontoblatt.
*/
public static function ordneKontoZu(int $bankKontoId, ?int $kontoId, bool $abrufen = true): void
{
Database::execute(
'UPDATE bank_konten bk
JOIN bank_zugaenge z ON z.id = bk.zugang_id
SET bk.konto_id = ?, bk.abrufen = ?
WHERE bk.id = ? AND z.benutzer_id = ?',
[$kontoId, $abrufen ? 1 : 0, $bankKontoId, Mandant::id()]
);
Database::execute(
"UPDATE bank_umsaetze
SET konto_id = ?
WHERE bank_konto_id = ? AND benutzer_id = ? AND status = 'offen'",
[$kontoId, $bankKontoId, Mandant::id()]
);
}
public static function merkeSaldo(int $bankKontoId, float $saldo): void
{
Database::execute(
'UPDATE bank_konten SET saldo = ?, saldo_am = NOW() WHERE id = ?',
[$saldo, $bankKontoId]
);
// Der Online-Saldo der Kontenübersicht kommt aus genau diesem Wert.
Database::execute(
'UPDATE konten k
JOIN bank_konten bk ON bk.konto_id = k.id
SET k.saldo_online = ?, k.saldo_online_am = NOW()
WHERE bk.id = ? AND k.benutzer_id = ?',
[$saldo, $bankKontoId, Mandant::id()]
);
}
}