'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> */ 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|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 $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 $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 $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 $daten * @return array */ 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 */ 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> */ 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 */ 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()] ); } }