Merge pull request 'Fix #117: Nicht freigegebene Überweisungen nachvollziehbar machen' (#118) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/118
This commit is contained in:
commit
28b8851e7b
15 changed files with 404 additions and 16 deletions
|
|
@ -1,5 +1,14 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Zahlungsverkehr: nicht freigegebene Überweisungen nachvollziehbar (06.10.2026)
|
||||||
|
|
||||||
|
- Anlass (#117): Die comdirect begründete zwei nicht ausgeführte Überweisungen damit, dass eine TAN angefordert, aber nicht verwendet wurde – im Finanzmanager war das nicht nachprüfbar.
|
||||||
|
- Verlangt die Bank eine Freigabe, steht der Auftrag auf „wartet auf Freigabe“ statt auf „Entwurf“; die Liste nennt, ob die TAN-Eingabe noch offen oder nicht abgeschlossen ist.
|
||||||
|
- Abbruch der Freigabe – auch durch einen neuen Sendeversuch – erzeugt einen Eintrag „Freigabe abgebrochen“ im Übermittlungsprotokoll und vermerkt den Auftrag als fehlgeschlagen.
|
||||||
|
- Aufträge mit Übermittlungsprotokoll lassen sich nicht mehr löschen, nur stornieren (Knopf im Formular).
|
||||||
|
- Migration `025_freigabe_nachvollziehen.sql`; E2E-Grundbestand enthält eine wartende Überweisung, der Aufräumer räumt `bank_protokolle` des Testnutzers mit ab.
|
||||||
|
- Tests: `tests/57_FreigabeNachvollziehenTest.php`, `tests/e2e/specs/zahlungsfreigabe.spec.js`.
|
||||||
|
|
||||||
## TAN-Anfrage: Startcode hervorheben (04.10.2026)
|
## TAN-Anfrage: Startcode hervorheben (04.10.2026)
|
||||||
|
|
||||||
- Nennt die Bank bei der TAN-Abfrage einen Startcode für den TAN-Generator, geht er im Fließtext leicht unter. Erkennt der Finanzmanager einen hinterlegten Beispielsatz (jede Ziffernfolge darin gilt als Platzhalter für den Startcode), zeigt er den gefundenen Code fett, zentriert und in eigener Zeile.
|
- Nennt die Bank bei der TAN-Abfrage einen Startcode für den TAN-Generator, geht er im Fließtext leicht unter. Erkennt der Finanzmanager einen hinterlegten Beispielsatz (jede Ziffernfolge darin gilt als Platzhalter für den Startcode), zeigt er den gefundenen Code fett, zentriert und in eigener Zeile.
|
||||||
|
|
|
||||||
|
|
@ -519,10 +519,21 @@ 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
|
Zu sehen ist das Protokoll an zwei Stellen: im Zahlungsverkehr über den Status der
|
||||||
Überweisung (modaler Dialog) und als Gesamtprotokoll unter *Verwaltung › Online-Banking ›
|
Überweisung (modaler Dialog) und als Gesamtprotokoll unter *Verwaltung › Online-Banking ›
|
||||||
Protokoll* (`/banking/protokoll`), filterbar nach Bank, Zeitraum und Freitext. Es wird nie
|
Protokoll* (`/banking/protokoll`), filterbar nach Bank, Zeitraum und Freitext. Es wird nie
|
||||||
automatisch gelöscht; wird ein Zahlungsauftrag entfernt, bleibt sein Protokoll stehen.
|
automatisch gelöscht. Ein Zahlungsauftrag mit Protokoll lässt sich nicht mehr löschen, nur
|
||||||
|
stornieren – er bleibt als Beleg stehen.
|
||||||
`tests/42_UebermittlungsprotokollTest.php` prüft Mitschnitt, Zuordnung zur Überweisung, den
|
`tests/42_UebermittlungsprotokollTest.php` prüft Mitschnitt, Zuordnung zur Überweisung, den
|
||||||
Fehlerfall und die Filter.
|
Fehlerfall und die Filter.
|
||||||
|
|
||||||
|
**Nicht erteilte Freigabe.** Verlangt die Bank eine TAN oder die Bestätigung des
|
||||||
|
Namensabgleichs, trägt der Auftrag bis dahin den Status `freigabe` („wartet auf Freigabe“) –
|
||||||
|
die Bank führt ihn ohne Freigabe nicht aus. Bricht der Nutzer ab oder löst ein neuer
|
||||||
|
Sendeversuch den wartenden Dialog ab, schreibt `FinTsUeberweisung::abbrechen()` einen
|
||||||
|
Protokolleintrag mit Status `abgebrochen` und vermerkt den Auftrag als fehlgeschlagen. Verfällt
|
||||||
|
nur die Sitzung, bleibt der Auftrag auf „wartet auf Freigabe“ stehen und die Liste weist darauf
|
||||||
|
hin, dass die Freigabe nicht abgeschlossen ist. So lässt sich eine Auskunft der Bank wie „TAN
|
||||||
|
angefordert, aber nicht verwendet“ am Protokoll prüfen. `tests/57_FreigabeNachvollziehenTest.php`
|
||||||
|
und `tests/e2e/specs/zahlungsfreigabe.spec.js`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein
|
`tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein
|
||||||
|
|
|
||||||
15
migrations/025_freigabe_nachvollziehen.sql
Normal file
15
migrations/025_freigabe_nachvollziehen.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Nicht freigegebene Überweisungen nachvollziehbar machen (#117).
|
||||||
|
--
|
||||||
|
-- Ein Zahlungsauftrag, für den die Bank eine TAN verlangt hat, trägt bis zur
|
||||||
|
-- Freigabe den Status „freigabe“ – er sieht dann nicht mehr aus wie ein nie
|
||||||
|
-- gesendeter Entwurf. Bricht der Nutzer die Freigabe ab, hält das
|
||||||
|
-- Übermittlungsprotokoll das mit dem Status „abgebrochen“ fest.
|
||||||
|
--
|
||||||
|
-- MODIFY mit vollständiger Werteliste ist wiederholbar und damit idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE zahlungsauftraege
|
||||||
|
MODIFY COLUMN status ENUM('entwurf','freigabe','gesendet','fehler','storniert')
|
||||||
|
NOT NULL DEFAULT 'entwurf';
|
||||||
|
|
||||||
|
ALTER TABLE bank_protokolle
|
||||||
|
MODIFY COLUMN status ENUM('erfolg','fehler','offen','abgebrochen') NOT NULL;
|
||||||
|
|
@ -2351,6 +2351,8 @@ table.liste tr.luecke td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.marke-entwurf { color: var(--warnung); border-color: var(--warnung); }
|
.marke-entwurf { color: var(--warnung); border-color: var(--warnung); }
|
||||||
|
.marke-freigabe { color: var(--warnung); border-color: var(--warnung); }
|
||||||
|
.marke-abgebrochen { color: var(--negativ); border-color: var(--negativ); }
|
||||||
.marke-gesendet { color: var(--positiv); border-color: var(--positiv); }
|
.marke-gesendet { color: var(--positiv); border-color: var(--positiv); }
|
||||||
.marke-storniert { color: var(--text-gedaempft); }
|
.marke-storniert { color: var(--text-gedaempft); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class ZahlungsverkehrController extends BaseController
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'offen' => Zahlungsauftrag::anzahlOffen(),
|
'offen' => Zahlungsauftrag::anzahlOffen(),
|
||||||
'wartetTan' => FinTsUeberweisung::wartetAufTan(),
|
'wartetTan' => FinTsUeberweisung::wartetAufTan(),
|
||||||
|
'wartender' => FinTsUeberweisung::wartenderAuftrag(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +148,7 @@ class ZahlungsverkehrController extends BaseController
|
||||||
$this->render('zahlungsverkehr/formular.php', [
|
$this->render('zahlungsverkehr/formular.php', [
|
||||||
'titel' => 'Überweisung an ' . $auftrag['empfaenger'],
|
'titel' => 'Überweisung an ' . $auftrag['empfaenger'],
|
||||||
'auftrag' => $auftrag,
|
'auftrag' => $auftrag,
|
||||||
|
'hatProtokoll' => Zahlungsauftrag::hatProtokoll((int)$auftrag['id']),
|
||||||
'konten' => $konten,
|
'konten' => $konten,
|
||||||
'adressen' => Adresse::alle(),
|
'adressen' => Adresse::alle(),
|
||||||
'kategorien' => Kategorie::baumFlach(null, true),
|
'kategorien' => Kategorie::baumFlach(null, true),
|
||||||
|
|
@ -260,7 +262,10 @@ class ZahlungsverkehrController extends BaseController
|
||||||
|
|
||||||
FinTsUeberweisung::abbrechen();
|
FinTsUeberweisung::abbrechen();
|
||||||
|
|
||||||
View::redirect('/zahlungsverkehr', 'Vorgang abgebrochen. Der Auftrag bleibt als Entwurf stehen.');
|
View::redirect(
|
||||||
|
'/zahlungsverkehr',
|
||||||
|
'Freigabe abgebrochen. Die Bank führt den Auftrag nicht aus – er kann erneut gesendet werden.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string,mixed> $ergebnis */
|
/** @param array<string,mixed> $ergebnis */
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class Bankprotokoll
|
||||||
'erfolg' => 'erfolgreich',
|
'erfolg' => 'erfolgreich',
|
||||||
'fehler' => 'fehlgeschlagen',
|
'fehler' => 'fehlgeschlagen',
|
||||||
'offen' => 'wartet auf Freigabe',
|
'offen' => 'wartet auf Freigabe',
|
||||||
|
'abgebrochen' => 'Freigabe abgebrochen',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function speichern(
|
public static function speichern(
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class Zahlungsauftrag
|
||||||
{
|
{
|
||||||
public const STATUS = [
|
public const STATUS = [
|
||||||
'entwurf' => 'Entwurf',
|
'entwurf' => 'Entwurf',
|
||||||
|
'freigabe' => 'wartet auf Freigabe',
|
||||||
'gesendet' => 'gesendet',
|
'gesendet' => 'gesendet',
|
||||||
'fehler' => 'fehlgeschlagen',
|
'fehler' => 'fehlgeschlagen',
|
||||||
'storniert' => 'storniert',
|
'storniert' => 'storniert',
|
||||||
|
|
@ -109,6 +110,12 @@ class Zahlungsauftrag
|
||||||
if ($auftrag !== null && $auftrag['status'] === 'gesendet') {
|
if ($auftrag !== null && $auftrag['status'] === 'gesendet') {
|
||||||
throw new \RuntimeException('Ein gesendeter Auftrag bleibt als Beleg stehen.');
|
throw new \RuntimeException('Ein gesendeter Auftrag bleibt als Beleg stehen.');
|
||||||
}
|
}
|
||||||
|
if ($auftrag !== null && self::hatProtokoll($id)) {
|
||||||
|
// Was die Bank gesehen hat, bleibt nachweisbar: stornieren ja, löschen nein.
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Zu diesem Auftrag gibt es ein Übermittlungsprotokoll – er bleibt als Beleg stehen und kann nur storniert werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Database::execute('DELETE FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
Database::execute('DELETE FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||||
}
|
}
|
||||||
|
|
@ -159,11 +166,35 @@ class Zahlungsauftrag
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hat der Auftrag die Bank schon einmal erreicht? Dann gibt es ein Protokoll. */
|
||||||
|
public static function hatProtokoll(int $id): bool
|
||||||
|
{
|
||||||
|
return (int)Database::fetchValue(
|
||||||
|
'SELECT COUNT(*) FROM bank_protokolle WHERE auftrag_id = ? AND benutzer_id = ?',
|
||||||
|
[$id, Mandant::id()],
|
||||||
|
0
|
||||||
|
) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Bank hat den Auftrag entgegengenommen, führt ihn aber erst nach der
|
||||||
|
* Freigabe (TAN, Namensabgleich) aus. Bis dahin ist er weder Entwurf noch
|
||||||
|
* gesendet.
|
||||||
|
*/
|
||||||
|
public static function alsWartendVermerken(int $id, string $meldung): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE zahlungsauftraege SET status = 'freigabe', meldung = ?
|
||||||
|
WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'",
|
||||||
|
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function alsFehlerVermerken(int $id, string $meldung): void
|
public static function alsFehlerVermerken(int $id, string $meldung): void
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
"UPDATE zahlungsauftraege SET status = 'fehler', meldung = ?
|
"UPDATE zahlungsauftraege SET status = 'fehler', meldung = ?
|
||||||
WHERE id = ? AND benutzer_id = ?",
|
WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'",
|
||||||
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +202,7 @@ class Zahlungsauftrag
|
||||||
public static function anzahlOffen(): int
|
public static function anzahlOffen(): int
|
||||||
{
|
{
|
||||||
return (int)Database::fetchValue(
|
return (int)Database::fetchValue(
|
||||||
"SELECT COUNT(*) FROM zahlungsauftraege WHERE benutzer_id = ? AND status = 'entwurf'",
|
"SELECT COUNT(*) FROM zahlungsauftraege WHERE benutzer_id = ? AND status IN ('entwurf', 'freigabe')",
|
||||||
[Mandant::id()],
|
[Mandant::id()],
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class E2EAufraeumer
|
||||||
'vertraege',
|
'vertraege',
|
||||||
'sparziele',
|
'sparziele',
|
||||||
'kredite',
|
'kredite',
|
||||||
|
'bank_protokolle',
|
||||||
'zahlungsauftraege',
|
'zahlungsauftraege',
|
||||||
'bank_umsaetze',
|
'bank_umsaetze',
|
||||||
'bank_abrufe',
|
'bank_abrufe',
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ namespace Finanzmanager\Services;
|
||||||
|
|
||||||
use Finanzmanager\Database;
|
use Finanzmanager\Database;
|
||||||
use Finanzmanager\Mandant;
|
use Finanzmanager\Mandant;
|
||||||
|
use Finanzmanager\Models\Bankprotokoll;
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
use Finanzmanager\Models\Depotbuchung;
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
use Finanzmanager\Models\Buchung;
|
use Finanzmanager\Models\Buchung;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Klasse;
|
use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Wertpapier;
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
use Finanzmanager\Models\Zahlungsauftrag;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt den Grundbestand an, auf dem die E2E-Tests aufsetzen.
|
* Legt den Grundbestand an, auf dem die E2E-Tests aufsetzen.
|
||||||
|
|
@ -35,6 +38,9 @@ use Finanzmanager\Models\Wertpapier;
|
||||||
* - **Ein Vorjahr.** Der Vergleichsbericht stellt zwei Zeiträume nebeneinander.
|
* - **Ein Vorjahr.** Der Vergleichsbericht stellt zwei Zeiträume nebeneinander.
|
||||||
* - **Ein Depot mit Wertpapier und Geschäft.** `/wertpapiere` und
|
* - **Ein Depot mit Wertpapier und Geschäft.** `/wertpapiere` und
|
||||||
* `/wertpapiere/buchungen` sind sonst leere Listen ohne Zahlenspalten.
|
* `/wertpapiere/buchungen` sind sonst leere Listen ohne Zahlenspalten.
|
||||||
|
* - **Eine Überweisung, die auf die Freigabe wartet.** Mit Übermittlungsprotokoll,
|
||||||
|
* damit der Zahlungsverkehr Status, Hinweis und Stornieren statt Löschen zeigt.
|
||||||
|
* Die Bankverbindung zeigt ins Leere; gesendet wird nichts.
|
||||||
*/
|
*/
|
||||||
class E2EGrundbestand
|
class E2EGrundbestand
|
||||||
{
|
{
|
||||||
|
|
@ -135,12 +141,33 @@ class E2EGrundbestand
|
||||||
]);
|
]);
|
||||||
$buchungen++;
|
$buchungen++;
|
||||||
|
|
||||||
|
$zugang = Bankzugang::anlegen([
|
||||||
|
'name' => self::PRAEFIX . ' Bank',
|
||||||
|
'bankname' => self::PRAEFIX . ' Bank AG',
|
||||||
|
'blz' => '12030000',
|
||||||
|
'fints_url' => 'https://banking.example.org/fints',
|
||||||
|
'benutzerkennung' => 'e2e',
|
||||||
|
'pin' => 'e2e-pin',
|
||||||
|
'abrufzeitraum' => 'letzter_abruf',
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$auftrag = Zahlungsauftrag::anlegen([
|
||||||
|
'konto_id' => $konten[0],
|
||||||
|
'zugang_id' => $zugang,
|
||||||
|
'empfaenger' => self::PRAEFIX . ' Finanzamt',
|
||||||
|
'iban' => 'DE02120300000000202051',
|
||||||
|
'betrag' => 4715.67,
|
||||||
|
]);
|
||||||
|
Bankprotokoll::speichern($zugang, 'ueberweisung', 'offen', 'Die Bank verlangt eine Freigabe.', '> HKTAN', $auftrag);
|
||||||
|
Zahlungsauftrag::alsWartendVermerken($auftrag, 'Die Bank verlangt eine Freigabe.');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'konten' => count($konten) + 1,
|
'konten' => count($konten) + 1,
|
||||||
'kategorien' => count($kategorien),
|
'kategorien' => count($kategorien),
|
||||||
'klassen' => 1,
|
'klassen' => 1,
|
||||||
'wertpapiere' => 1,
|
'wertpapiere' => 1,
|
||||||
'buchungen' => $buchungen,
|
'buchungen' => $buchungen,
|
||||||
|
'zahlungsauftraege' => 1,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,9 @@ class FinTsUeberweisung
|
||||||
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
|
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
self::vergessen();
|
// Wartet noch ein anderer Durchlauf auf seine Freigabe, verfällt er jetzt –
|
||||||
|
// das soll im Protokoll stehen und nicht stillschweigend passieren.
|
||||||
|
self::abbrechen('Abgelöst durch einen neuen Sendeversuch, bevor die Freigabe erteilt war.');
|
||||||
|
|
||||||
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
|
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +173,14 @@ class FinTsUeberweisung
|
||||||
return self::zustandLesen() !== null;
|
return self::zustandLesen() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Auftrag, dessen Freigabe in dieser Sitzung gerade aussteht. */
|
||||||
|
public static function wartenderAuftrag(): ?int
|
||||||
|
{
|
||||||
|
$auftragId = (int)(self::zustandLesen()['auftrag_id'] ?? 0);
|
||||||
|
|
||||||
|
return $auftragId === 0 ? null : $auftragId;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string,mixed>|null */
|
/** @return array<string,mixed>|null */
|
||||||
public static function offeneTanAnfrage(): ?array
|
public static function offeneTanAnfrage(): ?array
|
||||||
{
|
{
|
||||||
|
|
@ -179,9 +189,47 @@ class FinTsUeberweisung
|
||||||
return $zustand === null ? null : ($zustand['tan'] ?? null);
|
return $zustand === null ? null : ($zustand['tan'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function abbrechen(): void
|
/**
|
||||||
|
* Verwirft eine ausstehende Freigabe.
|
||||||
|
*
|
||||||
|
* Die Bank hat den Auftrag dann zwar gesehen und eine TAN angefordert, führt
|
||||||
|
* ihn ohne Freigabe aber nicht aus. Genau das muss sich später nachweisen
|
||||||
|
* lassen – deshalb ein Eintrag im Übermittlungsprotokoll und eine Meldung am
|
||||||
|
* Auftrag, statt den Zustand nur aus der Sitzung zu löschen.
|
||||||
|
*/
|
||||||
|
public static function abbrechen(string $grund = 'Vom Nutzer abgebrochen.'): void
|
||||||
{
|
{
|
||||||
|
$zustand = self::zustandLesen();
|
||||||
self::vergessen();
|
self::vergessen();
|
||||||
|
|
||||||
|
$auftragId = (int)($zustand['auftrag_id'] ?? 0);
|
||||||
|
if ($auftragId === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meldung = 'Freigabe nicht erteilt – keine TAN eingereicht, die Bank führt den Auftrag nicht aus. '
|
||||||
|
. $grund;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mitschnitt = new BankProtokollLogger();
|
||||||
|
$mitschnitt->vermerke(sprintf(
|
||||||
|
'Ausstehender Schritt: %s',
|
||||||
|
($zustand['schritt'] ?? '') === 'vop' ? 'Namensabgleich bestätigen' : 'TAN eingeben'
|
||||||
|
));
|
||||||
|
$mitschnitt->vermerke('Ergebnis: ' . $meldung);
|
||||||
|
|
||||||
|
Bankprotokoll::speichern(
|
||||||
|
(int)($zustand['zugang_id'] ?? 0),
|
||||||
|
'ueberweisung',
|
||||||
|
'abgebrochen',
|
||||||
|
$meldung,
|
||||||
|
$mitschnitt->text(),
|
||||||
|
$auftragId
|
||||||
|
);
|
||||||
|
Zahlungsauftrag::alsFehlerVermerken($auftragId, $meldung);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DebugLog::fehler($e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ablauf ───────────────────────────────────────────────────────────────
|
// ── Ablauf ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -218,6 +266,10 @@ class FinTsUeberweisung
|
||||||
};
|
};
|
||||||
$this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']);
|
$this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']);
|
||||||
|
|
||||||
|
if ($status === 'offen') {
|
||||||
|
Zahlungsauftrag::alsWartendVermerken($this->auftragId, (string)$ergebnis['meldung']);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Bankprotokoll::speichern(
|
Bankprotokoll::speichern(
|
||||||
(int)$this->zugang['id'],
|
(int)$this->zugang['id'],
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class Finanzkalender
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Zahlungsauftrag::alle('entwurf') as $auftrag) {
|
foreach ([...Zahlungsauftrag::alle('entwurf'), ...Zahlungsauftrag::alle('freigabe')] as $auftrag) {
|
||||||
$datum = (string)($auftrag['ausfuehrung_am'] ?? '');
|
$datum = (string)($auftrag['ausfuehrung_am'] ?? '');
|
||||||
if ($datum === '' || $datum > $bis) {
|
if ($datum === '' || $datum > $bis) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Finanzmanager\View;
|
||||||
* @var list<array<string,mixed>> $kategorien
|
* @var list<array<string,mixed>> $kategorien
|
||||||
* @var list<array<string,mixed>> $klassen
|
* @var list<array<string,mixed>> $klassen
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
|
* @var bool|null $hatProtokoll
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$neu = $auftrag === null;
|
$neu = $auftrag === null;
|
||||||
|
|
@ -170,12 +171,25 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if (!$neu && $auftrag['status'] !== 'gesendet'): ?>
|
<?php if (!$neu && $auftrag['status'] !== 'gesendet' && ($hatProtokoll ?? false)): ?>
|
||||||
|
<?php /* Hat die Bank den Auftrag gesehen, bleibt er als Beleg stehen. */ ?>
|
||||||
|
<?php if ($auftrag['status'] !== 'storniert'): ?>
|
||||||
|
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/stornieren" class="formular breit"
|
||||||
|
data-bestaetigen="Zahlungsauftrag stornieren?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf gefahr" id="auftrag-stornieren">Auftrag stornieren</button>
|
||||||
|
</div>
|
||||||
|
<p class="feldhinweis">Zu diesem Auftrag gibt es ein Übermittlungsprotokoll – er wird
|
||||||
|
nicht gelöscht, sondern bleibt storniert als Beleg stehen.</p>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif (!$neu && $auftrag['status'] !== 'gesendet'): ?>
|
||||||
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/loeschen" class="formular breit"
|
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/loeschen" class="formular breit"
|
||||||
data-bestaetigen="Zahlungsauftrag löschen?">
|
data-bestaetigen="Zahlungsauftrag löschen?">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf gefahr">Auftrag löschen</button>
|
<button type="submit" class="knopf gefahr" id="auftrag-loeschen">Auftrag löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use Finanzmanager\View;
|
||||||
/**
|
/**
|
||||||
* @var list<array<string,mixed>> $auftraege
|
* @var list<array<string,mixed>> $auftraege
|
||||||
* @var string $status
|
* @var string $status
|
||||||
|
* @var int|null $wartender
|
||||||
* @var int $offen
|
* @var int $offen
|
||||||
* @var bool $wartetTan
|
* @var bool $wartetTan
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
|
|
@ -39,7 +40,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Eine Überweisung wird erst gebucht, wenn die Bank sie angenommen hat – vorher steht sie
|
Eine Überweisung wird erst gebucht, wenn die Bank sie angenommen hat – vorher steht sie
|
||||||
als Entwurf hier. Ohne Ausführungstermin geht sie sofort hinaus, mit Termin als
|
als Entwurf hier, nach der TAN-Anforderung als „wartet auf Freigabe“. Ohne Ausführungstermin geht sie sofort hinaus, mit Termin als
|
||||||
Terminüberweisung, als Echtzeitüberweisung binnen Sekunden.
|
Terminüberweisung, als Echtzeitüberweisung binnen Sekunden.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -88,7 +89,13 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
|
<?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
|
||||||
</span>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (($auftrag['meldung'] ?? '') !== '' && $auftrag['status'] === 'fehler'): ?>
|
<?php if ($auftrag['status'] === 'freigabe'): ?>
|
||||||
|
<span class="nebentext">
|
||||||
|
<?= $wartender === (int)$auftrag['id']
|
||||||
|
? '<a href="/zahlungsverkehr/tan">TAN jetzt eingeben</a> – bis dahin führt die Bank den Auftrag nicht aus.'
|
||||||
|
: 'Freigabe nicht abgeschlossen – die Bank führt den Auftrag ohne TAN nicht aus. Erneut senden.' ?>
|
||||||
|
</span>
|
||||||
|
<?php elseif (($auftrag['meldung'] ?? '') !== '' && $auftrag['status'] === 'fehler'): ?>
|
||||||
<span class="nebentext"><?= View::h($auftrag['meldung']) ?></span>
|
<span class="nebentext"><?= View::h($auftrag['meldung']) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
175
tests/57_FreigabeNachvollziehenTest.php
Normal file
175
tests/57_FreigabeNachvollziehenTest.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Models\Bankprotokoll;
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Models\Zahlungsauftrag;
|
||||||
|
use Finanzmanager\Services\FinTsUeberweisung;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nicht freigegebene Überweisungen nachvollziehen (#117).
|
||||||
|
*
|
||||||
|
* Verlangt die Bank eine TAN und wird sie nie eingereicht, führt die Bank den
|
||||||
|
* Auftrag nicht aus. Geprüft wird, dass das am Auftrag sichtbar ist, dass der
|
||||||
|
* Abbruch im Übermittlungsprotokoll steht und dass ein Auftrag mit Protokoll
|
||||||
|
* nicht mehr gelöscht werden kann. Der wartende Dialog wird direkt in die
|
||||||
|
* Sitzung gelegt – es verlässt nichts den Container.
|
||||||
|
*/
|
||||||
|
class FreigabeNachvollziehenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 57: Freigabe nachvollziehen';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $zugang = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->einWartenderAuftragIstKeinEntwurf();
|
||||||
|
$this->derAbbruchStehtImProtokoll();
|
||||||
|
$this->einNeuerSendeversuchLoestDieFreigabeAbUndProtokolliert();
|
||||||
|
$this->ohneWartendeFreigabePassiertBeimAbbruchNichts();
|
||||||
|
$this->einAuftragMitProtokollWirdNichtGeloescht();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Freigabe-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->zugang = Bankzugang::anlegen([
|
||||||
|
'name' => 'Freigabe-Bank',
|
||||||
|
'bankname' => 'Freigabe-Bank AG',
|
||||||
|
'blz' => '12030000',
|
||||||
|
'fints_url' => 'https://banking.example.org/fints',
|
||||||
|
'benutzerkennung' => 'testkunde',
|
||||||
|
'pin' => 'geheim1234',
|
||||||
|
'abrufzeitraum' => 'letzter_abruf',
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Auf dem eigenen Rechner lauscht niemand – ein echter Versuch scheitert sofort.
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE bank_zugaenge SET fints_url = ? WHERE id = ?',
|
||||||
|
['http://127.0.0.1:9/fints', $this->zugang]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auftrag(): int
|
||||||
|
{
|
||||||
|
return Zahlungsauftrag::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'zugang_id' => $this->zugang,
|
||||||
|
'empfaenger' => 'Finanzamt Musterstadt',
|
||||||
|
'iban' => 'DE02120300000000202051',
|
||||||
|
'betrag' => 4715.67,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Dialog in die Sitzung, der auf die TAN zum Auftrag wartet. */
|
||||||
|
private function tanAngefordert(int $auftragId): void
|
||||||
|
{
|
||||||
|
Bankprotokoll::speichern($this->zugang, 'ueberweisung', 'offen', 'Die Bank verlangt eine Freigabe.', '> HKTAN', $auftragId);
|
||||||
|
Zahlungsauftrag::alsWartendVermerken($auftragId, 'Die Bank verlangt eine Freigabe.');
|
||||||
|
|
||||||
|
Auth::startSession();
|
||||||
|
$_SESSION[FinTsUeberweisung::SITZUNG] = [
|
||||||
|
'schritt' => 'tan',
|
||||||
|
'zugang_id' => $this->zugang,
|
||||||
|
'auftrag_id' => $auftragId,
|
||||||
|
'tan' => ['bezeichnung' => 'Überweisung'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function einWartenderAuftragIstKeinEntwurf(): void
|
||||||
|
{
|
||||||
|
$auftragId = $this->auftrag();
|
||||||
|
$this->tanAngefordert($auftragId);
|
||||||
|
|
||||||
|
$this->assertEquals('freigabe', Zahlungsauftrag::find($auftragId)['status'], 'Nach der TAN-Anforderung wartet der Auftrag auf Freigabe');
|
||||||
|
$this->assertEquals($auftragId, FinTsUeberweisung::wartenderAuftrag(), 'Die Sitzung kennt den wartenden Auftrag');
|
||||||
|
$this->assertEquals(1, count(Zahlungsauftrag::alle('freigabe')), 'Der Reiter „wartet auf Freigabe“ zeigt ihn');
|
||||||
|
$this->assertGreaterThan(0, Zahlungsauftrag::anzahlOffen(), 'Er zählt zu den offenen Aufträgen');
|
||||||
|
|
||||||
|
FinTsUeberweisung::abbrechen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derAbbruchStehtImProtokoll(): void
|
||||||
|
{
|
||||||
|
$auftragId = $this->auftrag();
|
||||||
|
$this->tanAngefordert($auftragId);
|
||||||
|
|
||||||
|
FinTsUeberweisung::abbrechen();
|
||||||
|
|
||||||
|
$this->assertNull(FinTsUeberweisung::wartenderAuftrag(), 'Nach dem Abbruch wartet nichts mehr');
|
||||||
|
|
||||||
|
$auftrag = Zahlungsauftrag::find($auftragId);
|
||||||
|
$this->assertEquals('fehler', $auftrag['status'], 'Der abgebrochene Auftrag gilt als nicht ausgeführt');
|
||||||
|
$this->assert(str_contains((string)$auftrag['meldung'], 'keine TAN eingereicht'), 'Die Meldung nennt die fehlende TAN');
|
||||||
|
|
||||||
|
$eintraege = Bankprotokoll::fuerAuftrag($auftragId);
|
||||||
|
$this->assertEquals(2, count($eintraege), 'TAN-Anforderung und Abbruch stehen im Protokoll');
|
||||||
|
$this->assertEquals('abgebrochen', $eintraege[1]['status'], 'Der Abbruch trägt den Status „abgebrochen“');
|
||||||
|
$this->assert(str_contains((string)$eintraege[1]['protokoll'], 'TAN eingeben'), 'Das Protokoll nennt den offenen Schritt');
|
||||||
|
$this->assert(str_contains(Bankprotokoll::nachweis($auftrag), 'Freigabe abgebrochen'), 'Der Nachweis zur Überweisung zeigt den Abbruch');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function einNeuerSendeversuchLoestDieFreigabeAbUndProtokolliert(): void
|
||||||
|
{
|
||||||
|
$alt = $this->auftrag();
|
||||||
|
$this->tanAngefordert($alt);
|
||||||
|
|
||||||
|
$neu = $this->auftrag();
|
||||||
|
$ergebnis = FinTsUeberweisung::senden($neu);
|
||||||
|
|
||||||
|
$this->assertEquals('fehler', $ergebnis['status'], 'Ohne erreichbare Bank scheitert der neue Versuch');
|
||||||
|
|
||||||
|
$eintraege = Bankprotokoll::fuerAuftrag($alt);
|
||||||
|
$this->assertEquals('abgebrochen', end($eintraege)['status'], 'Die verdrängte Freigabe steht als abgebrochen im Protokoll');
|
||||||
|
$this->assert(
|
||||||
|
str_contains((string)end($eintraege)['meldung'], 'neuen Sendeversuch'),
|
||||||
|
'Das Protokoll nennt den Grund'
|
||||||
|
);
|
||||||
|
$this->assertEquals('fehler', Zahlungsauftrag::find($alt)['status'], 'Der verdrängte Auftrag wartet nicht mehr');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ohneWartendeFreigabePassiertBeimAbbruchNichts(): void
|
||||||
|
{
|
||||||
|
$vorher = (int)Database::fetchValue('SELECT COUNT(*) FROM bank_protokolle', [], 0);
|
||||||
|
|
||||||
|
FinTsUeberweisung::abbrechen();
|
||||||
|
|
||||||
|
$nachher = (int)Database::fetchValue('SELECT COUNT(*) FROM bank_protokolle', [], 0);
|
||||||
|
$this->assertEquals($vorher, $nachher, 'Ohne wartende Freigabe entsteht kein Protokolleintrag');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function einAuftragMitProtokollWirdNichtGeloescht(): void
|
||||||
|
{
|
||||||
|
$mitProtokoll = $this->auftrag();
|
||||||
|
$this->tanAngefordert($mitProtokoll);
|
||||||
|
FinTsUeberweisung::abbrechen();
|
||||||
|
|
||||||
|
$abgelehnt = false;
|
||||||
|
try {
|
||||||
|
Zahlungsauftrag::loeschen($mitProtokoll);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
$abgelehnt = true;
|
||||||
|
}
|
||||||
|
$this->assert($abgelehnt, 'Ein Auftrag mit Übermittlungsprotokoll lässt sich nicht löschen');
|
||||||
|
$this->assert(Zahlungsauftrag::find($mitProtokoll) !== null, 'Er bleibt als Beleg stehen');
|
||||||
|
|
||||||
|
Zahlungsauftrag::stornieren($mitProtokoll);
|
||||||
|
$this->assertEquals('storniert', Zahlungsauftrag::find($mitProtokoll)['status'], 'Stornieren geht weiterhin');
|
||||||
|
|
||||||
|
$ohneProtokoll = $this->auftrag();
|
||||||
|
Zahlungsauftrag::loeschen($ohneProtokoll);
|
||||||
|
$this->assertNull(Zahlungsauftrag::find($ohneProtokoll), 'Ein nie gesendeter Entwurf lässt sich weiterhin löschen');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
tests/e2e/specs/zahlungsfreigabe.spec.js
Normal file
38
tests/e2e/specs/zahlungsfreigabe.spec.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test')
|
||||||
|
|
||||||
|
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||||
|
const PASSWORT = process.env.E2E_PASSWORT || process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||||
|
|
||||||
|
// Der Grundbestand (E2EGrundbestand) legt eine Überweisung an, für die die Bank
|
||||||
|
// eine TAN angefordert hat, die nie eingereicht wurde (#117).
|
||||||
|
const EMPFAENGER = 'E2E Grundbestand Finanzamt'
|
||||||
|
|
||||||
|
async function anmelden(page) {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.fill('#benutzername', BENUTZER)
|
||||||
|
await page.fill('#passwort', PASSWORT)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await expect(page).toHaveURL(/\/$/)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zahlungsverkehr: nicht freigegebene Überweisung', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Liste zeigt „wartet auf Freigabe“ mit Hinweis', async ({ page }) => {
|
||||||
|
await page.goto('/zahlungsverkehr?status=freigabe')
|
||||||
|
const zeile = page.locator('table.liste tr', { hasText: EMPFAENGER })
|
||||||
|
await expect(zeile).toHaveCount(1)
|
||||||
|
await expect(zeile.locator('.marke-freigabe')).toHaveText('wartet auf Freigabe')
|
||||||
|
await expect(zeile).toContainText('die Bank führt den Auftrag ohne TAN nicht aus')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Auftrag mit Übermittlungsprotokoll: Stornieren statt Löschen', async ({ page }) => {
|
||||||
|
await page.goto('/zahlungsverkehr?status=freigabe')
|
||||||
|
await page.locator('table.liste tr', { hasText: EMPFAENGER }).getByRole('link', { name: EMPFAENGER }).click()
|
||||||
|
await expect(page.locator('#auftrag-stornieren')).toBeVisible()
|
||||||
|
await expect(page.locator('#auftrag-loeschen')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue