Merge pull request 'Fix #67: Übermittlungsprotokoll für Überweisungen speichern' (#68) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/68
This commit is contained in:
fds2610 2026-09-21 08:12:57 +02:00
commit ba1747849d
21 changed files with 756 additions and 10 deletions

View file

@ -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:

View file

@ -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

View file

@ -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;

View file

@ -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());

View file

@ -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; }

View file

@ -284,6 +284,33 @@
});
}
// Übermittlungsprotokoll im Zahlungsverkehr: Ein Klick auf den Status holt das
// Protokoll der Überweisung in einen modalen Dialog. Ohne <dialog> 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.

View file

@ -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();

View file

@ -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<string,string> $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();

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Das Übermittlungsprotokoll: der vollständige Dialog mit der Bank, gesendete
* und empfangene Meldungen.
*
* Es dient als Nachweis, dass eine Überweisung rechtzeitig und korrekt
* übermittelt wurde – deshalb wird im Erfolgs- wie im Fehlerfall gespeichert
* und nichts davon automatisch gelöscht.
*/
class Bankprotokoll
{
public const ARTEN = [
'ueberweisung' => 'Ü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<array<string,mixed>>
*/
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<array<string,mixed>>
*/
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
);
}
}

View file

@ -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

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Psr\Log\AbstractLogger;
use Stringable;
/**
* Schneidet den FinTS-Dialog für das Übermittlungsprotokoll mit.
*
* Die Bibliothek meldet jede gesendete (`>`) 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<string> */
private array $zeilen = [];
private DebugLogAdapter $debug;
public function __construct()
{
$this->debug = new DebugLogAdapter();
}
/**
* @param mixed $level
* @param array<string,mixed> $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');
}
}

View file

@ -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<string,mixed> $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<string,mixed>
*/
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<string,mixed> */
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`).

View file

@ -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<string,mixed> $zugang mit entschlüsselter PIN
* @param array<string,mixed> $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<string,mixed> */
/**
* Ein Durchlauf samt Übermittlungsprotokoll: Was zwischen Finanzmanager und
* Bank gesendet und empfangen wurde, wird gespeichert – ob es klappt oder
* nicht.
*
* @return array<string,mixed>
*/
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<string,mixed> */
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.

View file

@ -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<string,mixed> */
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<string,mixed> $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;
}

View file

@ -16,6 +16,7 @@ include __DIR__ . '/../layout/kopf.php';
<span class="tab aktiv">Bankverbindungen</span>
<a href="/banking/umsaetze" class="tab">Umsätze abgleichen<?= $offen > 0 ? ' (' . $offen . ')' : '' ?></a>
<a href="/banking/regeln" class="tab">Regeln</a>
<a href="/banking/protokoll" class="tab">Protokoll</a>
</div>
<?php if (!$schluessel): ?>

View file

@ -0,0 +1,57 @@
<?php
use Finanzmanager\View;
/**
* Das Gesamtprotokoll: jede Kommunikation mit den Banken – Überweisungen,
* Umsatzabrufe, Abfragen der TAN-Verfahren. Nach Bank, Zeitraum und Freitext
* filterbar.
*
* @var list<array<string,mixed>> $eintraege
* @var list<array<string,mixed>> $zugaenge
* @var array{zugang_id: int, von: string, bis: string, text: string} $filter
*/
$mitAuftrag = true;
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/banking" class="tab">Bankverbindungen</a>
<a href="/banking/umsaetze" class="tab">Umsätze abgleichen</a>
<a href="/banking/regeln" class="tab">Regeln</a>
<span class="tab aktiv">Protokoll</span>
</div>
<form method="get" action="/banking/protokoll" class="filterleiste">
<input type="search" name="text" placeholder="Freitext in den Meldungen"
value="<?= View::h($filter['text']) ?>">
<select name="zugang_id" aria-label="Bank">
<option value="">Alle Banken</option>
<?php foreach ($zugaenge as $zugang): ?>
<option value="<?= (int)$zugang['id'] ?>"
<?= $filter['zugang_id'] === (int)$zugang['id'] ? 'selected' : '' ?>>
<?= View::h($zugang['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label>von <input type="date" name="von" value="<?= View::h($filter['von']) ?>"></label>
<label>bis <input type="date" name="bis" value="<?= View::h($filter['bis']) ?>"></label>
<button type="submit" class="knopf klein primaer">Filtern</button>
<a href="/banking/protokoll" class="knopf klein">Zurücksetzen</a>
</form>
<p class="hinweis">
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.
</p>
<?php if ($eintraege === []): ?>
<p class="platzhalter">Keine Einträge.</p>
<?php else: ?>
<?php include __DIR__ . '/protokolleintraege.php'; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,39 @@
<?php
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\View;
/**
* Die Durchläufe des Übermittlungsprotokolls. Jeder steht in einem eigenen
* Block; die Meldungen im Wortlaut liegen aufgeklappt darunter.
*
* @var list<array<string,mixed>> $eintraege
* @var bool $mitAuftrag Ob zu jedem Eintrag der Auftrag genannt wird
*/
?>
<?php foreach ($eintraege as $eintrag): ?>
<section class="protokolleintrag">
<header>
<strong><?= View::h(date('d.m.Y H:i:s', strtotime((string)$eintrag['erstellt_am']))) ?></strong>
<span class="marke marke-<?= View::h($eintrag['status']) ?>">
<?= View::h(Bankprotokoll::STATUS[$eintrag['status']] ?? $eintrag['status']) ?>
</span>
<span class="nebentext">
<?= View::h(Bankprotokoll::ARTEN[$eintrag['art']] ?? $eintrag['art']) ?>
<?php if (($eintrag['zugangsname'] ?? '') !== ''): ?>
· <?= View::h($eintrag['zugangsname']) ?>
<?php endif; ?>
<?php if (!empty($mitAuftrag) && ($eintrag['empfaenger'] ?? null) !== null): ?>
· an <?= View::h($eintrag['empfaenger']) ?>, <?= View::h(View::betrag($eintrag['betrag'])) ?>
<?php endif; ?>
</span>
</header>
<?php if (($eintrag['meldung'] ?? '') !== ''): ?>
<p><?= View::h($eintrag['meldung']) ?></p>
<?php endif; ?>
<details>
<summary>Gesendete und empfangene Meldungen</summary>
<pre class="protokolltext"><?= View::h($eintrag['protokoll'] ?? '') ?></pre>
</details>
</section>
<?php endforeach; ?>

View file

@ -68,6 +68,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="knopfleiste">
<a href="/banking" class="knopf klein primaer">Bankverbindungen</a>
<a href="/banking/regeln" class="knopf klein">Regeln</a>
<a href="/banking/protokoll" class="knopf klein">Protokoll</a>
</div>
</div>

View file

@ -71,9 +71,19 @@ include __DIR__ . '/../layout/kopf.php';
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?>
</td>
<td>
<?php if ((int)$auftrag['protokolle'] > 0): ?>
<?php /* Der Status öffnet das Übermittlungsprotokoll; ohne
Skript führt der Link auf dieselbe Ansicht als Seite. */ ?>
<a href="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/protokoll"
class="marke marke-<?= View::h($auftrag['status']) ?>" data-protokoll
title="Übermittlungsprotokoll anzeigen">
<?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
</a>
<?php else: ?>
<span class="marke marke-<?= View::h($auftrag['status']) ?>">
<?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
</span>
<?php endif; ?>
<?php if (($auftrag['meldung'] ?? '') !== '' && $auftrag['status'] === 'fehler'): ?>
<span class="nebentext"><?= View::h($auftrag['meldung']) ?></span>
<?php endif; ?>
@ -99,4 +109,11 @@ include __DIR__ . '/../layout/kopf.php';
</table>
<?php endif; ?>
<dialog class="splitdialog protokolldialog" id="protokolldialog">
<div data-protokoll-inhalt></div>
<form method="dialog" class="knopfleiste">
<button type="submit" class="knopf klein">Schließen</button>
</form>
</dialog>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,35 @@
<?php
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\View;
/**
* Das Übermittlungsprotokoll einer Überweisung – als Fragment für den Dialog
* im Zahlungsverkehr, sonst als eigene Seite.
*
* @var array<string,mixed> $auftrag
* @var list<array<string,mixed>> $eintraege
* @var bool $fragment
*/
if (!$fragment) {
include __DIR__ . '/../layout/kopf.php';
}
?>
<h2>Übermittlungsprotokoll</h2>
<p class="nebentext">
Überweisung über <?= View::h(View::betrag($auftrag['betrag'])) ?> an
<?= View::h($auftrag['empfaenger']) ?> – <?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
</p>
<?php if ($eintraege === []): ?>
<p class="platzhalter">Zu dieser Überweisung liegt kein Protokoll vor.</p>
<?php else: ?>
<?php $mitAuftrag = false; include __DIR__ . '/../banking/protokolleintraege.php'; ?>
<?php endif; ?>
<?php if (!$fragment): ?>
<p><a href="/zahlungsverkehr" class="knopf">Zurück zum Zahlungsverkehr</a></p>
<?php include __DIR__ . '/../layout/fuss.php'; ?>
<?php endif; ?>

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\BankProtokollLogger;
use Finanzmanager\Services\FinTsUeberweisung;
/**
* Übermittlungsprotokoll: Nachweis gegenüber der Bank.
*
* Geprüft wird, dass der Dialog mit der Bank gespeichert wird – im Erfolgs- wie
* im Fehlerfall –, dass er zur Überweisung wiederzufinden ist und dass das
* Gesamtprotokoll nach Bank, Datum und Freitext filtert. Für den Fehlerfall geht
* die Überweisung an eine Adresse auf dem eigenen Rechner, auf der niemand
* lauscht: Es verlässt nichts den Container.
*/
class UebermittlungsprotokollTest extends TestCase
{
protected string $testName = 'Test 42: Übermittlungsprotokoll';
private int $giro = 0;
private int $zugang = 0;
private int $zweiterZugang = 0;
public function run(): void
{
$this->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');
}
}