From 3caf1dcdca3855f104961193381d9f174a55df28 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 21 Sep 2026 07:48:10 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#67:=20=C3=9Cbermittlungsprotokoll=20f?= =?UTF-8?q?=C3=BCr=20=C3=9Cberweisungen=20und=20Bankkommunikation=20speich?= =?UTF-8?q?ern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der gesamte FinTS-Dialog (gesendete und empfangene Meldungen) wird je Durchlauf in bank_protokolle gespeichert, im Erfolgs- wie im Fehlerfall. Im Zahlungsverkehr öffnet der Status das Protokoll der Überweisung als modalen Dialog; unter Online-Banking › Protokoll gibt es das Gesamtprotokoll mit Filter nach Bank, Datum und Freitext. Co-Authored-By: Claude Sonnet 5 --- Changes.md | 19 +++ docs/banking.md | 17 ++ migrations/018_bank_protokolle.sql | 31 ++++ public/index.php | 2 + public/static/app.css | 17 ++ public/static/app.js | 27 ++++ src/Controllers/BankingController.php | 21 +++ src/Controllers/ZahlungsverkehrController.php | 25 +++ src/Models/Bankprotokoll.php | 115 ++++++++++++++ src/Models/Zahlungsauftrag.php | 3 +- src/Services/BankProtokollLogger.php | 63 ++++++++ src/Services/FinTsAbruf.php | 33 +++- src/Services/FinTsUeberweisung.php | 57 ++++++- src/Services/FinTsVerfahren.php | 32 +++- src/Views/banking/index.php | 1 + src/Views/banking/protokoll.php | 57 +++++++ src/Views/banking/protokolleintraege.php | 39 +++++ src/Views/verwaltung/index.php | 1 + src/Views/zahlungsverkehr/index.php | 23 ++- src/Views/zahlungsverkehr/protokoll.php | 35 +++++ tests/42_UebermittlungsprotokollTest.php | 148 ++++++++++++++++++ 21 files changed, 756 insertions(+), 10 deletions(-) create mode 100644 migrations/018_bank_protokolle.sql create mode 100644 src/Models/Bankprotokoll.php create mode 100644 src/Services/BankProtokollLogger.php create mode 100644 src/Views/banking/protokoll.php create mode 100644 src/Views/banking/protokolleintraege.php create mode 100644 src/Views/zahlungsverkehr/protokoll.php create mode 100644 tests/42_UebermittlungsprotokollTest.php diff --git a/Changes.md b/Changes.md index e3398e7..57683d9 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Änderungen +## Übermittlungsprotokoll für Überweisungen und Bankkommunikation (21.09.2026) + +Als Nachweis gegenüber der Bank, dass eine Überweisung fristgerecht und korrekt übermittelt +wurde, speichert der Finanzmanager jetzt den gesamten Dialog – gesendete und empfangene +Meldungen im Wortlaut, im Erfolgs- wie im Fehlerfall. PIN und Benutzerkennung sind maskiert +(das erledigt die FinTS-Bibliothek, bevor die Zeilen ankommen). + +- **Zahlungsverkehr:** Ein Klick auf den Status einer Überweisung öffnet ihr + Übermittlungsprotokoll in einem modalen Dialog. Eine Überweisung mit TAN-Freigabe hat + mehrere Durchläufe; alle stehen darin, der älteste zuerst. Ohne JavaScript führt der + Status auf dieselbe Ansicht als eigene Seite. +- **Verwaltung › Online-Banking › Protokoll** (`/banking/protokoll`): das Gesamtprotokoll + über jede Kommunikation mit den Banken – Überweisungen, Umsatzabrufe und die Abfrage der + TAN-Verfahren. Filterbar nach Bank, Zeitraum und Freitext. +- Neue Tabelle `bank_protokolle` (Migration `018`), Modell `Bankprotokoll`, Mitschnitt über + `Services\BankProtokollLogger`. Das Protokoll bleibt stehen, auch wenn die Überweisung + gelöscht wird; es wird nicht automatisch aufgeräumt. +- Test 42 (`tests/42_UebermittlungsprotokollTest.php`). + ## Letzter roter E2E-Test aus dem Typeahead-Umbau behoben (04.09.2026) Ein Test blieb nach der Anpassung an den Typeahead (siehe unten) noch rot: diff --git a/docs/banking.md b/docs/banking.md index 2317b75..0ea8d43 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -467,6 +467,23 @@ Bei eingeschaltetem Debug-Log (Einstellungen → Diagnose) landen zusätzlich di FinTS-Dialogschritte in `data/logs/` – über `Services\DebugLogAdapter`, der den PSR-3-Logger der Bibliothek an das eigene Log hängt. PIN und TAN maskieren beide Seiten. +### Übermittlungsprotokoll + +Als Nachweis, dass eine Überweisung rechtzeitig und korrekt bei der Bank ankam, speichert +jeder Durchlauf – Überweisung, Umsatzabruf, Abfrage der TAN-Verfahren – seinen gesamten +Dialog in `bank_protokolle`: gesendete (`>`) und empfangene (`<`) Meldungen im Wortlaut, im +Erfolgs- wie im Fehlerfall. Den Mitschnitt macht `Services\BankProtokollLogger`, ein +PSR-3-Logger, den die Dienste der Bibliothek statt des `DebugLogAdapter` mitgeben; er läuft +also unabhängig vom Debug-Log. Die Bibliothek maskiert PIN und Benutzerkennung, bevor eine +Zeile ankommt. Eine Überweisung mit TAN-Freigabe hat mehrere Durchläufe. + +Zu sehen ist das Protokoll an zwei Stellen: im Zahlungsverkehr über den Status der +Überweisung (modaler Dialog) und als Gesamtprotokoll unter *Verwaltung › Online-Banking › +Protokoll* (`/banking/protokoll`), filterbar nach Bank, Zeitraum und Freitext. Es wird nie +automatisch gelöscht; wird ein Zahlungsauftrag entfernt, bleibt sein Protokoll stehen. +`tests/42_UebermittlungsprotokollTest.php` prüft Mitschnitt, Zuordnung zur Überweisung, den +Fehlerfall und die Filter. + ## Tests `tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein diff --git a/migrations/018_bank_protokolle.sql b/migrations/018_bank_protokolle.sql new file mode 100644 index 0000000..05cbe95 --- /dev/null +++ b/migrations/018_bank_protokolle.sql @@ -0,0 +1,31 @@ +-- Übermittlungsprotokoll: jede Kommunikation mit einer Bank. +-- +-- Zum Nachweis gegenüber der Bank, dass eine Überweisung fristgerecht und +-- korrekt übermittelt wurde, bleibt der gesamte Dialog stehen – die gesendeten +-- und die empfangenen Meldungen, im Erfolgs- wie im Fehlerfall. PIN und TAN +-- sind vor dem Speichern maskiert (siehe Services\BankProtokollLogger). +-- +-- Ein Eintrag ist ein Durchlauf: Eine Überweisung mit TAN-Freigabe hat deshalb +-- mehrere. `auftrag_id` verbindet sie mit dem Zahlungsauftrag; die Verbindung +-- fällt weg, wenn der Auftrag gelöscht wird, das Protokoll selbst bleibt. +CREATE TABLE IF NOT EXISTS bank_protokolle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + zugang_id INT UNSIGNED NULL, + auftrag_id INT UNSIGNED NULL, + art ENUM('ueberweisung','abruf','verfahren') NOT NULL, + status ENUM('erfolg','fehler','offen') NOT NULL, + meldung TEXT NULL, + protokoll LONGTEXT NULL, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_protokoll_auftrag (auftrag_id), + KEY idx_protokoll_zugang (zugang_id, erstellt_am), + KEY idx_protokoll_datum (benutzer_id, erstellt_am), + CONSTRAINT fk_protokoll_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE, + CONSTRAINT fk_protokoll_zugang FOREIGN KEY (zugang_id) + REFERENCES bank_zugaenge (id) ON DELETE SET NULL, + CONSTRAINT fk_protokoll_auftrag FOREIGN KEY (auftrag_id) + REFERENCES zahlungsauftraege (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index 312d930..e5eb1ef 100644 --- a/public/index.php +++ b/public/index.php @@ -254,6 +254,7 @@ $router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrCont $router->get('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopFormular()); $router->post('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopBestaetigen()); +$router->get('/zahlungsverkehr/{id}/protokoll', fn(array $p) => (new ZahlungsverkehrController())->protokoll($p)); $router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p)); $router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p)); $router->post('/zahlungsverkehr/{id}/senden', fn(array $p) => (new ZahlungsverkehrController())->senden($p)); @@ -305,6 +306,7 @@ $router->post('/banking/umsaetze/{id}/verwerfen', $router->post('/banking/umsaetze/{id}/zuruecksetzen', fn(array $p) => (new BankingController())->zuruecksetzen($p)); +$router->get('/banking/protokoll', fn() => (new BankingController())->protokoll()); $router->get('/banking/regeln', fn() => (new BankingController())->regeln()); $router->post('/banking/regeln/neu', fn() => (new BankingController())->regelAnlegen()); $router->post('/banking/regeln/pruefen', fn() => (new BankingController())->regelnPruefen()); diff --git a/public/static/app.css b/public/static/app.css index 034c909..402d749 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -2012,6 +2012,7 @@ table.bericht .wertlink:hover { ein voll gefüllter Punkt zöge in einer Tabelle zu viel Aufmerksamkeit. */ .marke-erfolg { color: var(--positiv); border-color: var(--positiv); } .marke-fehler { color: var(--negativ); border-color: var(--negativ); } +.marke-offen { color: var(--warnung); border-color: var(--warnung); } .marke-tan_offen { color: var(--warnung); border-color: var(--warnung); } .marke-abgebrochen { color: var(--text-gedaempft); } @@ -2290,3 +2291,19 @@ table.liste tr.luecke td { border-bottom-color: #fff; } } + +/* Übermittlungsprotokoll: ein Block je Durchlauf, die Meldungen im Wortlaut + klappen darunter auf. */ +.protokolldialog { width: min(60rem, 94vw); } + +.protokolleintrag { + margin: 0 0 .8rem; + padding: .6rem .75rem; + border: 1px solid var(--rand); + border-radius: var(--radius); +} + +.protokolleintrag header { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; } +.protokolleintrag p { margin: .4rem 0; } +.protokolleintrag summary { cursor: pointer; font-size: .88rem; color: var(--text-gedaempft); } +.protokolleintrag .protokolltext { max-height: 22rem; overflow: auto; word-break: break-all; } diff --git a/public/static/app.js b/public/static/app.js index bc91ce5..1fa92ea 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -284,6 +284,33 @@ }); } + // Übermittlungsprotokoll im Zahlungsverkehr: Ein Klick auf den Status holt das + // Protokoll der Überweisung in einen modalen Dialog. Ohne oder + // fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite. + const protokolldialog = document.getElementById('protokolldialog'); + if (protokolldialog && typeof protokolldialog.showModal === 'function' && window.fetch) { + const inhalt = protokolldialog.querySelector('[data-protokoll-inhalt]'); + + document.querySelectorAll('[data-protokoll]').forEach(function (link) { + link.addEventListener('click', function (ereignis) { + ereignis.preventDefault(); + + window.fetch(link.getAttribute('href'), { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin' + }).then(function (antwort) { + if (!antwort.ok) { throw new Error(antwort.status); } + return antwort.text(); + }).then(function (html) { + inhalt.innerHTML = html; + protokolldialog.showModal(); + }).catch(function () { + window.location.href = link.getAttribute('href'); + }); + }); + }); + } + // Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die // Content-Security-Policy (style-src 'self') verwirft auch per JavaScript // gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen. diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 7330470..a236fcc 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -9,6 +9,7 @@ use Finanzmanager\Database; 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\Kategorie; @@ -70,6 +71,26 @@ class BankingController extends BaseController * zwei ausgegrauten Hälften wäre für niemanden eine Hilfe. Die Wahl fällt * deshalb vorweg und ist danach nicht mehr umzustellen. */ + /** Das Gesamtprotokoll: jede Kommunikation mit den Banken, filterbar. */ + public function protokoll(): void + { + $this->verlangeAnmeldung(); + + $filter = [ + 'zugang_id' => (int)$this->query('zugang_id'), + 'von' => $this->query('von'), + 'bis' => $this->query('bis'), + 'text' => $this->query('text'), + ]; + + $this->render('banking/protokoll.php', [ + 'titel' => 'Protokoll', + 'eintraege' => Bankprotokoll::suchen($filter), + 'zugaenge' => Bankzugang::alle(), + 'filter' => $filter, + ]); + } + public function neu(): void { $this->verlangeAnmeldung(); diff --git a/src/Controllers/ZahlungsverkehrController.php b/src/Controllers/ZahlungsverkehrController.php index 5cd9d7c..98c18d4 100644 --- a/src/Controllers/ZahlungsverkehrController.php +++ b/src/Controllers/ZahlungsverkehrController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; use Finanzmanager\Models\Adresse; +use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; @@ -33,6 +34,30 @@ class ZahlungsverkehrController extends BaseController ]); } + /** + * Das Übermittlungsprotokoll einer Überweisung. Das Skript holt es als + * Fragment in den Dialog; ohne Skript ist es eine eigene Seite. + * + * @param array $params + */ + public function protokoll(array $params): void + { + $this->verlangeAnmeldung(); + + $auftrag = Zahlungsauftrag::find((int)$params['id']); + if ($auftrag === null) { + View::redirect('/zahlungsverkehr', 'Den Zahlungsauftrag gibt es nicht.', 'fehler'); + return; + } + + $this->render('zahlungsverkehr/protokoll.php', [ + 'titel' => 'Übermittlungsprotokoll', + 'auftrag' => $auftrag, + 'eintraege' => Bankprotokoll::fuerAuftrag((int)$auftrag['id']), + 'fragment' => $this->istAjax(), + ]); + } + public function neu(): void { $this->verlangeAnmeldung(); diff --git a/src/Models/Bankprotokoll.php b/src/Models/Bankprotokoll.php new file mode 100644 index 0000000..9f6b867 --- /dev/null +++ b/src/Models/Bankprotokoll.php @@ -0,0 +1,115 @@ + 'Überweisung', + 'abruf' => 'Umsatzabruf', + 'verfahren' => 'TAN-Verfahren', + ]; + + public const STATUS = [ + 'erfolg' => 'erfolgreich', + 'fehler' => 'fehlgeschlagen', + 'offen' => 'wartet auf Freigabe', + ]; + + public static function speichern( + ?int $zugangId, + string $art, + string $status, + string $meldung, + string $protokoll, + ?int $auftragId = null + ): int { + Database::execute( + 'INSERT INTO bank_protokolle + (benutzer_id, zugang_id, auftrag_id, art, status, meldung, protokoll) + VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + Mandant::id(), + $zugangId === 0 ? null : $zugangId, + $auftragId === 0 ? null : $auftragId, + array_key_exists($art, self::ARTEN) ? $art : 'abruf', + array_key_exists($status, self::STATUS) ? $status : 'fehler', + mb_substr($meldung, 0, 2000), + $protokoll, + ] + ); + + return Database::lastInsertId(); + } + + /** + * Alle Durchläufe zu einer Überweisung, der älteste zuerst. + * + * @return list> + */ + public static function fuerAuftrag(int $auftragId): array + { + return Database::fetchAll( + 'SELECT p.*, z.name AS zugangsname + FROM bank_protokolle p + LEFT JOIN bank_zugaenge z ON z.id = p.zugang_id + WHERE p.auftrag_id = ? AND p.benutzer_id = ? + ORDER BY p.erstellt_am, p.id', + [$auftragId, Mandant::id()] + ); + } + + /** + * Das Gesamtprotokoll, das neueste zuerst. + * + * @param array{zugang_id?: int, von?: string, bis?: string, text?: string} $filter + * @return list> + */ + public static function suchen(array $filter = [], int $limit = 200): array + { + $params = [Mandant::id()]; + $sql = 'SELECT p.*, z.name AS zugangsname, a.empfaenger, a.betrag + FROM bank_protokolle p + LEFT JOIN bank_zugaenge z ON z.id = p.zugang_id + LEFT JOIN zahlungsauftraege a ON a.id = p.auftrag_id + WHERE p.benutzer_id = ?'; + + if ((int)($filter['zugang_id'] ?? 0) > 0) { + $sql .= ' AND p.zugang_id = ?'; + $params[] = (int)$filter['zugang_id']; + } + if (Buchung::istDatum((string)($filter['von'] ?? ''))) { + $sql .= ' AND p.erstellt_am >= ?'; + $params[] = $filter['von'] . ' 00:00:00'; + } + if (Buchung::istDatum((string)($filter['bis'] ?? ''))) { + $sql .= ' AND p.erstellt_am <= ?'; + $params[] = $filter['bis'] . ' 23:59:59'; + } + + $text = trim((string)($filter['text'] ?? '')); + if ($text !== '') { + $muster = '%' . addcslashes($text, '\\%_') . '%'; + $sql .= ' AND (p.protokoll LIKE ? OR p.meldung LIKE ? OR a.empfaenger LIKE ?)'; + array_push($params, $muster, $muster, $muster); + } + + return Database::fetchAll( + $sql . ' ORDER BY p.erstellt_am DESC, p.id DESC LIMIT ' . max(1, $limit), + $params + ); + } +} diff --git a/src/Models/Zahlungsauftrag.php b/src/Models/Zahlungsauftrag.php index d238166..8a5253f 100644 --- a/src/Models/Zahlungsauftrag.php +++ b/src/Models/Zahlungsauftrag.php @@ -27,7 +27,8 @@ class Zahlungsauftrag public static function alle(string $status = ''): array { $params = [Mandant::id()]; - $sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad + $sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, + (SELECT COUNT(*) FROM bank_protokolle p WHERE p.auftrag_id = z.id) AS protokolle FROM zahlungsauftraege z JOIN konten k ON k.id = z.konto_id LEFT JOIN kategorien kat ON kat.id = z.kategorie_id diff --git a/src/Services/BankProtokollLogger.php b/src/Services/BankProtokollLogger.php new file mode 100644 index 0000000..1c79d92 --- /dev/null +++ b/src/Services/BankProtokollLogger.php @@ -0,0 +1,63 @@ +`) und empfangene (`<`) Nachricht an + * ihren Logger – unabhängig davon, ob das Debug-Log an ist. Dieser Logger + * sammelt alles im Speicher und reicht es zusätzlich an das Debug-Log weiter. + * Was ankommt, hat die Bibliothek bereits von PIN und Benutzerkennung befreit + * (`SanitizingLogger`). + */ +class BankProtokollLogger extends AbstractLogger +{ + /** @var list */ + private array $zeilen = []; + + private DebugLogAdapter $debug; + + public function __construct() + { + $this->debug = new DebugLogAdapter(); + } + + /** + * @param mixed $level + * @param array $context + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $this->debug->log($level, $message, $context); + + $this->vermerke('[' . (is_scalar($level) ? (string)$level : 'info') . '] ' . (string)$message); + } + + /** Eine eigene Zeile (etwa ein Schritt der Anwendung) ins Protokoll. */ + public function vermerke(string $zeile): void + { + $this->zeilen[] = date('Y-m-d H:i:s') . ' ' . self::alsUtf8($zeile); + } + + public function text(): string + { + return implode("\n", $this->zeilen); + } + + /** + * Die Wire-Nachrichten kommen in ISO-8859-1; die Datenbank nimmt nur + * gültiges UTF-8. + */ + private static function alsUtf8(string $text): string + { + return mb_check_encoding($text, 'UTF-8') + ? $text + : mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1'); + } +} diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 1f5ad67..bf42753 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -17,6 +17,7 @@ use Fhp\Options\FinTsOptions; use Finanzmanager\Auth; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankabruf; +use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Einstellung; @@ -67,12 +68,16 @@ class FinTsAbruf private FinTs $fints; + /** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */ + private BankProtokollLogger $mitschnitt; + /** @param array $zugang mit entschlüsselter PIN */ private function __construct( private array $zugang, private int $abrufId, private array $zustand ) { + $this->mitschnitt = new BankProtokollLogger(); } /** @@ -194,6 +199,32 @@ class FinTsAbruf * @return array */ 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 */ + private function durchlauf(?string $tan): array { $this->protokoll = $this->zustand['protokoll'] ?? []; @@ -568,7 +599,7 @@ class FinTsAbruf ); $fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null); - $fints->setLogger(new DebugLogAdapter()); + $fints->setLogger($this->mitschnitt); // Beim ersten Lauf steht das Verfahren noch nicht im Dialog – es kommt // aus der Einstellung des Zugangs (siehe `Services\FinTsVerfahren`). diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index a351906..091cb3a 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -16,6 +16,7 @@ use Fhp\Options\Credentials; use Fhp\Options\FinTsOptions; use Fhp\Segment\SPA\HISPAS; use Finanzmanager\Auth; +use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Konto; @@ -52,6 +53,9 @@ class FinTsUeberweisung /** … und so lange warten wir dazwischen höchstens (Sekunden). */ private const POLLING_WARTEZEIT = 6; + /** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */ + private BankProtokollLogger $mitschnitt; + /** * @param array $zugang mit entschlüsselter PIN * @param array $zustand @@ -61,6 +65,7 @@ class FinTsUeberweisung private int $auftragId, private array $zustand ) { + $this->mitschnitt = new BankProtokollLogger(); } /** @@ -168,8 +173,56 @@ class FinTsUeberweisung // ── Ablauf ─────────────────────────────────────────────────────────────── - /** @return array */ + /** + * Ein Durchlauf samt Übermittlungsprotokoll: Was zwischen Finanzmanager und + * Bank gesendet und empfangen wurde, wird gespeichert – ob es klappt oder + * nicht. + * + * @return array + */ private function lauf(?string $tan, bool $vopBestaetigt = false): array + { + $auftrag = Zahlungsauftrag::find($this->auftragId); + if ($auftrag !== null) { + $this->mitschnitt->vermerke(sprintf( + 'Überweisung %d: %s, IBAN %s, %s %s, Ausführung %s', + $this->auftragId, + $auftrag['empfaenger'], + $auftrag['iban'], + number_format((float)$auftrag['betrag'], 2, ',', '.'), + $auftrag['waehrung'], + $auftrag['ausfuehrung_am'] ?? 'sofort' + )); + } + + $ergebnis = $this->durchlauf($tan, $vopBestaetigt); + + $status = match ($ergebnis['status']) { + 'erfolg' => 'erfolg', + 'tan', 'vop' => 'offen', + default => 'fehler', + }; + $this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']); + + try { + Bankprotokoll::speichern( + (int)$this->zugang['id'], + 'ueberweisung', + $status, + (string)$ergebnis['meldung'], + $this->mitschnitt->text(), + $this->auftragId + ); + } catch (\Throwable $e) { + // Ein Fehler im Protokoll darf die Überweisung nicht verdecken. + DebugLog::fehler($e); + } + + return $ergebnis; + } + + /** @return array */ + private function durchlauf(?string $tan, bool $vopBestaetigt): array { $auftrag = Zahlungsauftrag::find($this->auftragId); if ($auftrag === null) { @@ -600,7 +653,7 @@ class FinTsUeberweisung ), $this->zustand['dialog'] ?? null ); - $fints->setLogger(new DebugLogAdapter()); + $fints->setLogger($this->mitschnitt); // Dieselbe Wahl wie beim Umsatzabruf – eine Überweisung soll nicht // plötzlich ein anderes Verfahren verlangen als der Abruf. diff --git a/src/Services/FinTsVerfahren.php b/src/Services/FinTsVerfahren.php index 9d49dbf..2b1cad1 100644 --- a/src/Services/FinTsVerfahren.php +++ b/src/Services/FinTsVerfahren.php @@ -8,6 +8,7 @@ use Fhp\FinTs; use Fhp\Model\TanMode; use Fhp\Options\Credentials; use Fhp\Options\FinTsOptions; +use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Einstellung; @@ -44,6 +45,31 @@ class FinTsVerfahren * hinweis: string}>} */ public static function abfragen(int $zugangId): array + { + $mitschnitt = new BankProtokollLogger(); + $ergebnis = self::abfrageDurchfuehren($zugangId, $mitschnitt); + + if (Bankzugang::zugangsdaten($zugangId) !== null) { + $mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']); + try { + Bankprotokoll::speichern( + $zugangId, + 'verfahren', + $ergebnis['status'] === 'erfolg' ? 'erfolg' : 'fehler', + $ergebnis['meldung'], + $mitschnitt->text() + ); + } catch (\Throwable $e) { + // Ein Fehler im Protokoll darf die Abfrage nicht verdecken. + DebugLog::fehler($e); + } + } + + return $ergebnis; + } + + /** @return array */ + private static function abfrageDurchfuehren(int $zugangId, BankProtokollLogger $mitschnitt): array { $zugang = Bankzugang::zugangsdaten($zugangId); if ($zugang === null) { @@ -63,7 +89,7 @@ class FinTsVerfahren } try { - $fints = self::verbindung($zugang); + $fints = self::verbindung($zugang, $mitschnitt); $modi = $fints->getTanModes(); } catch (\Throwable $e) { DebugLog::fehler($e); @@ -270,7 +296,7 @@ class FinTsVerfahren } /** @param array $zugang */ - private static function verbindung(array $zugang): FinTs + private static function verbindung(array $zugang, BankProtokollLogger $mitschnitt): FinTs { $optionen = new FinTsOptions(); $optionen->url = (string)$zugang['fints_url']; @@ -288,7 +314,7 @@ class FinTsVerfahren $optionen, Credentials::create((string)$zugang['benutzerkennung'], (string)$zugang['pin']) ); - $fints->setLogger(new DebugLogAdapter()); + $fints->setLogger($mitschnitt); return $fints; } diff --git a/src/Views/banking/index.php b/src/Views/banking/index.php index 5b683ec..33dde49 100644 --- a/src/Views/banking/index.php +++ b/src/Views/banking/index.php @@ -16,6 +16,7 @@ include __DIR__ . '/../layout/kopf.php'; Bankverbindungen Umsätze abgleichen 0 ? ' (' . $offen . ')' : '' ?> Regeln + Protokoll diff --git a/src/Views/banking/protokoll.php b/src/Views/banking/protokoll.php new file mode 100644 index 0000000..784dfe9 --- /dev/null +++ b/src/Views/banking/protokoll.php @@ -0,0 +1,57 @@ +> $eintraege + * @var list> $zugaenge + * @var array{zugang_id: int, von: string, bis: string, text: string} $filter + */ + +$mitAuftrag = true; + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ + + + + + Zurücksetzen +
+ +

+ Das Protokoll hält fest, was der Finanzmanager an die Bank gesendet und was sie geantwortet + hat – als Nachweis, dass eine Überweisung rechtzeitig übermittelt wurde. PIN und TAN sind + maskiert. +

+ + +

Keine Einträge.

+ + + + + diff --git a/src/Views/banking/protokolleintraege.php b/src/Views/banking/protokolleintraege.php new file mode 100644 index 0000000..1029d55 --- /dev/null +++ b/src/Views/banking/protokolleintraege.php @@ -0,0 +1,39 @@ +> $eintraege + * @var bool $mitAuftrag Ob zu jedem Eintrag der Auftrag genannt wird + */ +?> + +
+
+ + + + + + + + · + + + · an , + + +
+ +

+ +
+ Gesendete und empfangene Meldungen +
+
+
+ diff --git a/src/Views/verwaltung/index.php b/src/Views/verwaltung/index.php index b7e1574..c5d0bd6 100644 --- a/src/Views/verwaltung/index.php +++ b/src/Views/verwaltung/index.php @@ -68,6 +68,7 @@ include __DIR__ . '/../layout/kopf.php'; diff --git a/src/Views/zahlungsverkehr/index.php b/src/Views/zahlungsverkehr/index.php index 279e806..7238316 100644 --- a/src/Views/zahlungsverkehr/index.php +++ b/src/Views/zahlungsverkehr/index.php @@ -71,9 +71,19 @@ include __DIR__ . '/../layout/kopf.php'; : View::h(View::datum($auftrag['ausfuehrung_am'])) ?> - - - + 0): ?> + + + + + + + + + @@ -99,4 +109,11 @@ include __DIR__ . '/../layout/kopf.php'; + +
+
+ +
+
+ diff --git a/src/Views/zahlungsverkehr/protokoll.php b/src/Views/zahlungsverkehr/protokoll.php new file mode 100644 index 0000000..74d0d17 --- /dev/null +++ b/src/Views/zahlungsverkehr/protokoll.php @@ -0,0 +1,35 @@ + $auftrag + * @var list> $eintraege + * @var bool $fragment + */ + +if (!$fragment) { + include __DIR__ . '/../layout/kopf.php'; +} +?> + +

Übermittlungsprotokoll

+

+ Überweisung über an + – +

+ + +

Zu dieser Überweisung liegt kein Protokoll vor.

+ + + + + +

Zurück zum Zahlungsverkehr

+ + diff --git a/tests/42_UebermittlungsprotokollTest.php b/tests/42_UebermittlungsprotokollTest.php new file mode 100644 index 0000000..97a0577 --- /dev/null +++ b/tests/42_UebermittlungsprotokollTest.php @@ -0,0 +1,148 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->derMitschnittSammeltGesendetUndEmpfangen(); + $this->einProtokollHaengtAmAuftrag(); + $this->einFehlgeschlagenerVersuchHinterlaesstEinProtokoll(); + $this->dasGesamtprotokollFiltert(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Protokoll-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $zugang = static fn(string $name): array => [ + 'name' => $name, + 'bankname' => $name . ' AG', + 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', + 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]; + + $this->zugang = Bankzugang::anlegen($zugang('Protokoll-Bank')); + $this->zweiterZugang = Bankzugang::anlegen($zugang('Andere-Bank')); + } + + private function auftrag(): int + { + return Zahlungsauftrag::anlegen([ + 'konto_id' => $this->giro, + 'zugang_id' => $this->zugang, + 'empfaenger' => 'Finanzamt Musterstadt', + 'iban' => 'DE02120300000000202051', + 'betrag' => 42.5, + ]); + } + + private function derMitschnittSammeltGesendetUndEmpfangen(): void + { + $logger = new BankProtokollLogger(); + $logger->debug('> HNHBK:1:3+000000000123'); + $logger->debug("< HNHBK:1:3+000000000456 Ümlaut\xE4"); + $logger->vermerke('Ergebnis: angenommen'); + + $text = $logger->text(); + $this->assert(str_contains($text, '> HNHBK:1:3+000000000123'), 'Die gesendete Meldung steht im Mitschnitt'); + $this->assert(str_contains($text, '< HNHBK:1:3+000000000456'), 'Die empfangene Meldung steht im Mitschnitt'); + $this->assert(str_contains($text, 'Ergebnis: angenommen'), 'Auch die eigenen Vermerke stehen darin'); + $this->assert(mb_check_encoding($text, 'UTF-8'), 'Der Mitschnitt ist gültiges UTF-8, auch nach Latin-1'); + } + + private function einProtokollHaengtAmAuftrag(): void + { + $auftragId = $this->auftrag(); + + Bankprotokoll::speichern($this->zugang, 'ueberweisung', 'offen', 'TAN nötig', '> Anfrage', $auftragId); + Bankprotokoll::speichern($this->zugang, 'ueberweisung', 'erfolg', 'angenommen', '< Antwort', $auftragId); + + $eintraege = Bankprotokoll::fuerAuftrag($auftragId); + $this->assertEquals(2, count($eintraege), 'Beide Durchläufe gehören zur Überweisung'); + $this->assertEquals('offen', $eintraege[0]['status'], 'Der älteste Durchlauf steht zuerst'); + $this->assertEquals('< Antwort', $eintraege[1]['protokoll'], 'Das Protokoll bleibt vollständig erhalten'); + + $liste = Zahlungsauftrag::alle(); + $this->assertEquals(2, (int)$liste[0]['protokolle'], 'Die Liste zählt die Protokolle je Überweisung'); + } + + private function einFehlgeschlagenerVersuchHinterlaesstEinProtokoll(): void + { + // Auf dem eigenen Rechner lauscht niemand – der Versuch scheitert sofort. + Database::execute( + 'UPDATE bank_zugaenge SET fints_url = ? WHERE id = ?', + ['http://127.0.0.1:9/fints', $this->zugang] + ); + + $auftragId = $this->auftrag(); + $ergebnis = FinTsUeberweisung::senden($auftragId); + FinTsUeberweisung::abbrechen(); + + $this->assertEquals('fehler', $ergebnis['status'], 'Ohne erreichbare Bank scheitert das Senden'); + + $eintraege = Bankprotokoll::fuerAuftrag($auftragId); + $this->assertEquals(1, count($eintraege), 'Auch der Fehlerfall wird protokolliert'); + $this->assertEquals('fehler', $eintraege[0]['status'], 'Der Eintrag trägt den Status „fehler“'); + $this->assert( + str_contains((string)$eintraege[0]['protokoll'], 'Finanzamt Musterstadt'), + 'Das Protokoll nennt die Überweisung' + ); + $this->assert( + !str_contains((string)$eintraege[0]['protokoll'], 'geheim1234'), + 'Die PIN steht nicht im Protokoll' + ); + } + + private function dasGesamtprotokollFiltert(): void + { + Bankprotokoll::speichern($this->zweiterZugang, 'abruf', 'erfolg', 'Umsätze geholt', 'Kontoauszug Nummer 77'); + + $alle = Bankprotokoll::suchen(); + $this->assertGreaterThan(3, count($alle), 'Das Gesamtprotokoll führt alle Durchläufe'); + + $andere = Bankprotokoll::suchen(['zugang_id' => $this->zweiterZugang]); + $this->assertEquals(1, count($andere), 'Der Bankfilter zeigt nur diese Bank'); + + $treffer = Bankprotokoll::suchen(['text' => 'Nummer 77']); + $this->assertEquals(1, count($treffer), 'Die Freitextsuche findet den Wortlaut der Meldungen'); + + $heute = date('Y-m-d'); + $this->assertGreaterThan(0, count(Bankprotokoll::suchen(['von' => $heute, 'bis' => $heute])), 'Heute ist im Zeitraum'); + $this->assertEquals(0, count(Bankprotokoll::suchen(['bis' => '2000-01-01'])), 'Ein Zeitraum in der Vergangenheit ist leer'); + $this->assertEquals(0, count(Bankprotokoll::suchen(['text' => '100%'])), 'Sonderzeichen der Suche sind wörtlich gemeint'); + } +}