Fix #71: Echtzeitüberweisung im Überweisungsdialog

Der Überweisungsdialog bietet das Häkchen „Echtzeitüberweisung“. SEPA
behandelt sie als eigenes Verfahren: Über FinTS geht der Auftrag als HKIPZ
(SendSEPARealtimeTransfer) statt als HKCCS hinaus, die pain.001-Nachricht
trägt die Zahlungsart LclInstrm/Cd = INST (nur im ISO-Format
pain.001.001.03). Eine Echtzeitüberweisung hat keinen Ausführungstermin;
bietet die Bank sie nicht an, scheitert der Auftrag mit klarer Meldung,
eine stille Umwandlung in eine gewöhnliche Überweisung ist abgeschaltet.

Neue Spalte zahlungsauftraege.echtzeit (Migration 019), Test 43 und ein
E2E-Test für das Sperren des Termins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-25 15:21:04 +02:00
parent ba1747849d
commit 822458ee0f
13 changed files with 429 additions and 23 deletions

View file

@ -1,5 +1,25 @@
# Änderungen # Änderungen
## Echtzeitüberweisung im Zahlungsverkehr (25.09.2026)
Der Überweisungsdialog bietet jetzt das Häkchen **Echtzeitüberweisung**: Das Geld ist binnen
Sekunden beim Empfänger. SEPA behandelt die Echtzeitüberweisung als eigenes Verfahren
(SCT Inst), der Finanzmanager deshalb auch.
- Über FinTS geht der Auftrag als Geschäftsvorfall HKIPZ hinaus (`SendSEPARealtimeTransfer`
aus php-fints) statt als HKCCS. In der pain.001-Nachricht steht die Zahlungsart
`LclInstrm/Cd = INST`; das gibt es nur im ISO-Format `pain.001.001.03`.
- Eine Echtzeitüberweisung hat keinen Ausführungstermin: Der Dialog sperrt das Feld, ein
Termin in der Zukunft wird beim Speichern abgewiesen.
- Bietet die Bank keine Echtzeitüberweisung an, scheitert der Auftrag mit einer klaren
Meldung und bleibt stehen. Eine stille Umwandlung in eine gewöhnliche Überweisung durch
die Bank ist abgeschaltet.
- Die Übersicht kennzeichnet Echtzeitüberweisungen in der Spalte „Ausführung“, das
Übermittlungsprotokoll nennt sie ausdrücklich.
- Neue Spalte `zahlungsauftraege.echtzeit` (Migration `019`).
- Test 43 (`tests/43_EchtzeitueberweisungTest.php`) und ein E2E-Test für das Sperren des
Termins.
## Übermittlungsprotokoll für Überweisungen und Bankkommunikation (21.09.2026) ## Übermittlungsprotokoll für Überweisungen und Bankkommunikation (21.09.2026)
Als Nachweis gegenüber der Bank, dass eine Überweisung fristgerecht und korrekt übermittelt Als Nachweis gegenüber der Bank, dass eine Überweisung fristgerecht und korrekt übermittelt

View file

@ -44,7 +44,7 @@ Haushalt wird beides parallel genutzt.
eines wählbaren Zeitraums. eines wählbaren Zeitraums.
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. * **Zahlungsverkehr** – Überweisungen, Terminüberweisungen und Echtzeitüberweisungen über FinTS.
* **Abgleich mit dem Bargeld-Tool** – wer Bargeldausgaben in einem zweiten Programm * **Abgleich mit dem Bargeld-Tool** – wer Bargeldausgaben in einem zweiten Programm
erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht: erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht:
grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts. grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts.

View file

@ -131,6 +131,17 @@ Fehlgeschlagene tragen die Meldung der Bank daneben.
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
Terminüberweisung; sie erscheint dann im Finanzkalender. Terminüberweisung; sie erscheint dann im Finanzkalender.
**Echtzeitüberweisung.** Das Häkchen „Echtzeitüberweisung“ im Überweisungsdialog
(Spalte `zahlungsauftraege.echtzeit`) macht aus dem Auftrag eine SEPA-Echtzeitüberweisung
(SCT Inst): Das Geld ist binnen Sekunden beim Empfänger. SEPA behandelt sie als eigenes
Verfahren – über FinTS geht sie als Geschäftsvorfall **HKIPZ** hinaus statt als HKCCS, in
der pain.001-Nachricht steht zusätzlich die Zahlungsart `PmtTpInf/LclInstrm/Cd = INST`.
Einen Ausführungstermin gibt es dafür nicht; der Dialog sperrt das Feld, das Modell weist
einen Termin in der Zukunft ab. Bietet die Bank keine Echtzeitüberweisung an (kein
Segment HIIPZS in den Bankparametern), scheitert der Auftrag mit einer klaren Meldung.
Eine Umwandlung in eine gewöhnliche Überweisung erlaubt der Finanzmanager der Bank
ausdrücklich nicht – wer Echtzeit wählt, soll erfahren, wenn es nicht klappt.
**Welches Konto bei der Bank gemeint ist**, entscheidet dieselbe Zuordnung wie beim **Welches Konto bei der Bank gemeint ist**, entscheidet dieselbe Zuordnung wie beim
Umsatzabruf: die Zeile in `bank_konten`, die dem Konto zugewiesen ist (IBAN, ersatzweise Umsatzabruf: die Zeile in `bank_konten`, die dem Konto zugewiesen ist (IBAN, ersatzweise
Kontonummer). Die IBAN am Konto selbst ist optional und wird nur herangezogen, wenn keine Kontonummer). Die IBAN am Konto selbst ist optional und wird nur herangezogen, wenn keine
@ -147,6 +158,8 @@ anderen nur das ältere DK-Format `pain.001.003.03` – und weisen alles andere
Einreichen ab. Welche Formate eine Bank führt, steht in den Bankparametern (Segment Einreichen ab. Welche Formate eine Bank führt, steht in den Bankparametern (Segment
HISPAS); `SepaXml::schemaWaehlen()` sucht daraus das passende aus, ISO bevorzugt. Für die HISPAS); `SepaXml::schemaWaehlen()` sucht daraus das passende aus, ISO bevorzugt. Für die
Einzelüberweisung sind beide Fassungen im Aufbau gleich, es genügt der andere Namensraum. Einzelüberweisung sind beide Fassungen im Aufbau gleich, es genügt der andere Namensraum.
Die Echtzeitüberweisung gibt es nur im ISO-Format: Das DK-Format kennt die Zahlungsart
`LclInstrm` nicht. Ihre Formate nennt die Bank in HIIPZS, ersatzweise gelten die aus HISPAS.
Der **SEPA-Zeichenvorrat** ist eng: `a–z A–Z 0–9 / - ? : ( ) . , ' +` und Leerzeichen. Der **SEPA-Zeichenvorrat** ist eng: `a–z A–Z 0–9 / - ? : ( ) . , ' +` und Leerzeichen.
Umlaute werden umgeschrieben (ä → ae), alles Übrige fällt weg. Ohne diese Umschrift weist Umlaute werden umgeschrieben (ä → ae), alles Übrige fällt weg. Ohne diese Umschrift weist

View file

@ -0,0 +1,11 @@
-- Echtzeitüberweisung (SEPA Instant Credit Transfer).
--
-- Eine Echtzeitüberweisung ist im SEPA-Verfahren ein eigener Geschäftsvorfall:
-- Über FinTS geht sie als HKIPZ statt als HKCCS hinaus, und in der
-- pain.001-Nachricht steht die Zahlungsart INST. Sie wird sofort ausgeführt,
-- einen Ausführungstermin gibt es dafür nicht.
--
-- Idempotent über IF NOT EXISTS.
ALTER TABLE zahlungsauftraege
ADD COLUMN IF NOT EXISTS echtzeit TINYINT(1) NOT NULL DEFAULT 0 AFTER ausfuehrung_am;

View file

@ -192,6 +192,22 @@
}); });
} }
// Echtzeitüberweisung: Sie geht sofort hinaus, ein Ausführungstermin hat
// daneben keinen Sinn. Das Feld wird gesperrt, solange sie gewählt ist –
// die Prüfung beim Speichern bleibt trotzdem die maßgebliche.
const echtzeit = document.querySelector('[data-echtzeit]');
const termin = document.getElementById('ausfuehrung_am');
if (echtzeit && termin) {
const abgleichen = function () {
termin.disabled = echtzeit.checked;
if (echtzeit.checked) {
termin.value = '';
}
};
echtzeit.addEventListener('change', abgleichen);
abgleichen();
}
// Kontenzuordnung einer Bankverbindung: Die Felder für ein neues Konto // Kontenzuordnung einer Bankverbindung: Die Felder für ein neues Konto
// stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn // stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn
// „neues Konto anlegen“ gewählt ist. Ausgeblendet wird hier und nicht im // „neues Konto anlegen“ gewählt ist. Ausgeblendet wird hier und nicht im

View file

@ -303,6 +303,7 @@ class ZahlungsverkehrController extends BaseController
'waehrung' => $this->post('waehrung', 'EUR'), 'waehrung' => $this->post('waehrung', 'EUR'),
'verwendungszweck' => $this->post('verwendungszweck'), 'verwendungszweck' => $this->post('verwendungszweck'),
'ausfuehrung_am' => $this->post('ausfuehrung_am'), 'ausfuehrung_am' => $this->post('ausfuehrung_am'),
'echtzeit' => isset($_POST['echtzeit']),
'kategorie_id' => $this->post('kategorie_id'), 'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'), 'klasse_id' => $this->post('klasse_id'),
]; ];

View file

@ -11,7 +11,9 @@ use Finanzmanager\Mandant;
* Überweisungen: erfasst, geprüft, über FinTS abgesendet. * Überweisungen: erfasst, geprüft, über FinTS abgesendet.
* *
* Der Auftrag bleibt als Beleg stehen, auch wenn die Bank ihn ablehnt – mit der * Der Auftrag bleibt als Beleg stehen, auch wenn die Bank ihn ablehnt – mit der
* Meldung daneben. Erst wenn die Bank ihn angenommen hat, entsteht die * Meldung daneben. Eine Überweisung geht als gewöhnliche Überweisung,
* Terminüberweisung oder Echtzeitüberweisung hinaus; die Echtzeitüberweisung
* behandelt SEPA als eigenen Geschäftsvorfall und kennt keinen Termin. Erst wenn die Bank ihn angenommen hat, entsteht die
* zugehörige Buchung auf dem Auftragskonto; vorher wäre sie eine Behauptung. * zugehörige Buchung auf dem Auftragskonto; vorher wäre sie eine Behauptung.
*/ */
class Zahlungsauftrag class Zahlungsauftrag
@ -63,12 +65,12 @@ class Zahlungsauftrag
Database::execute( Database::execute(
'INSERT INTO zahlungsauftraege 'INSERT INTO zahlungsauftraege
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung, (benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
verwendungszweck, ausfuehrung_am, kategorie_id, klasse_id, status) verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
$f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'],
$f['ausfuehrung_am'], $f['kategorie_id'], $f['klasse_id'], 'entwurf', $f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], 'entwurf',
] ]
); );
@ -91,12 +93,12 @@ class Zahlungsauftrag
Database::execute( Database::execute(
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?, 'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?, bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL
WHERE id = ? AND benutzer_id = ?', WHERE id = ? AND benutzer_id = ?',
[ [
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'], $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
$f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'],
$f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(), $f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(),
] ]
); );
} }
@ -209,6 +211,15 @@ class Zahlungsauftrag
throw new \InvalidArgumentException('Der Ausführungstermin liegt in der Vergangenheit.'); throw new \InvalidArgumentException('Der Ausführungstermin liegt in der Vergangenheit.');
} }
// Eine Echtzeitüberweisung ist binnen Sekunden beim Empfänger – einen
// Termin kennt sie nicht. Der heutige Tag ist ohnehin „sofort“.
$echtzeit = !empty($daten['echtzeit']);
if ($echtzeit && $termin !== '' && $termin > date('Y-m-d')) {
throw new \InvalidArgumentException(
'Eine Echtzeitüberweisung wird sofort ausgeführt und kann keinen Ausführungstermin haben.'
);
}
$id = static fn(mixed $wert): ?int => $id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert; ($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
@ -223,7 +234,8 @@ class Zahlungsauftrag
// 140 Zeichen sind das SEPA-Maß für den Verwendungszweck; was // 140 Zeichen sind das SEPA-Maß für den Verwendungszweck; was
// darüber steht, würde die Bank ohnehin abschneiden. // darüber steht, würde die Bank ohnehin abschneiden.
'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140), 'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140),
'ausfuehrung_am' => $termin !== '' ? $termin : null, 'ausfuehrung_am' => $termin !== '' && !$echtzeit ? $termin : null,
'echtzeit' => $echtzeit ? 1 : 0,
'kategorie_id' => $id($daten['kategorie_id'] ?? null), 'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null), 'klasse_id' => $id($daten['klasse_id'] ?? null),
]; ];

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Finanzmanager\Services; namespace Finanzmanager\Services;
use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\SendSEPARealtimeTransfer;
use Fhp\Action\SendSEPATransfer; use Fhp\Action\SendSEPATransfer;
use Fhp\BaseAction; use Fhp\BaseAction;
use Fhp\FinTs; use Fhp\FinTs;
@ -14,6 +15,8 @@ use Fhp\Model\VopConfirmationRequest;
use Fhp\Model\VopVerificationResult; use Fhp\Model\VopVerificationResult;
use Fhp\Options\Credentials; use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions; use Fhp\Options\FinTsOptions;
use Fhp\Segment\IPZ\HIIPZSv1;
use Fhp\Segment\IPZ\HIIPZSv2;
use Fhp\Segment\SPA\HISPAS; use Fhp\Segment\SPA\HISPAS;
use Finanzmanager\Auth; use Finanzmanager\Auth;
use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankprotokoll;
@ -40,6 +43,13 @@ use Finanzmanager\Models\Zahlungsauftrag;
* überein, geht es ohne Rückfrage weiter zur TAN. Weicht der Name ab oder konnte * überein, geht es ohne Rückfrage weiter zur TAN. Weicht der Name ab oder konnte
* die Bank nicht prüfen, wird zuerst gefragt – mit dem Text, den die Bank dazu * die Bank nicht prüfen, wird zuerst gefragt – mit dem Text, den die Bank dazu
* mitliefert. * mitliefert.
*
* **Echtzeitüberweisung.** Ist sie am Auftrag gewählt, geht der Auftrag als
* eigener Geschäftsvorfall HKIPZ hinaus statt als HKCCS – so will es das
* SEPA-Verfahren. Bietet die Bank das nicht an, scheitert der Auftrag mit einer
* klaren Meldung und bleibt stehen. Eine stille Umwandlung in eine gewöhnliche
* Überweisung erlauben wir der Bank nicht: Wer Echtzeit wählt, braucht das Geld
* sofort beim Empfänger und soll erfahren, wenn das nicht klappt.
*/ */
class FinTsUeberweisung class FinTsUeberweisung
{ {
@ -185,7 +195,8 @@ class FinTsUeberweisung
$auftrag = Zahlungsauftrag::find($this->auftragId); $auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag !== null) { if ($auftrag !== null) {
$this->mitschnitt->vermerke(sprintf( $this->mitschnitt->vermerke(sprintf(
'Überweisung %d: %s, IBAN %s, %s %s, Ausführung %s', '%s %d: %s, IBAN %s, %s %s, Ausführung %s',
self::istEchtzeit($auftrag) ? 'Echtzeitüberweisung' : 'Überweisung',
$this->auftragId, $this->auftragId,
$auftrag['empfaenger'], $auftrag['empfaenger'],
$auftrag['iban'], $auftrag['iban'],
@ -307,17 +318,29 @@ class FinTsUeberweisung
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.'); return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
} }
$formate = $this->sepaFormate($fints); $echtzeit = self::istEchtzeit($auftrag);
$schema = SepaXml::schemaWaehlen($formate); if ($echtzeit && $this->echtzeitParameter($fints) === null) {
return $this->fehler(
'Die Bank bietet über FinTS keine Echtzeitüberweisung an. '
. 'Bitte die Echtzeitüberweisung am Auftrag abwählen und erneut senden.'
);
}
$formate = $echtzeit ? $this->echtzeitFormate($fints) : $this->sepaFormate($fints);
$schema = SepaXml::schemaWaehlen($formate, $echtzeit);
if ($schema === null) { if ($schema === null) {
return $this->fehler( return $this->fehler(
'Die Bank nimmt nur SEPA-Formate an, die der Finanzmanager nicht erzeugt: ' ($echtzeit ? 'Für Echtzeitüberweisungen nimmt die Bank' : 'Die Bank nimmt')
. ' nur SEPA-Formate an, die der Finanzmanager nicht erzeugt: '
. implode(', ', $formate) . implode(', ', $formate)
); );
} }
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema); $xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
$aktion = SendSEPATransfer::create($sepa, $xml); $aktion = $echtzeit
// Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
? SendSEPARealtimeTransfer::create($sepa, $xml, false)
: SendSEPATransfer::create($sepa, $xml);
$fints->execute($aktion); $fints->execute($aktion);
return $this->nachDemSenden($fints, $aktion); return $this->nachDemSenden($fints, $aktion);
@ -352,6 +375,42 @@ class FinTsUeberweisung
: []; : [];
} }
/**
* Die Bankparameter der Echtzeitüberweisung (Segment HIIPZS) – oder `null`,
* wenn die Bank sie über FinTS nicht anbietet.
*/
private function echtzeitParameter(FinTs $fints): HIIPZSv1|HIIPZSv2|null
{
try {
$hiipzs = $fints->getBpd()->getLatestSupportedParameters('HIIPZS');
} catch (\Throwable) {
return null;
}
return $hiipzs instanceof HIIPZSv1 || $hiipzs instanceof HIIPZSv2 ? $hiipzs : null;
}
/**
* Die SEPA-Datenformate für Echtzeitüberweisungen. Nennt die Bank dafür
* keine eigenen, gelten die allgemeinen aus HISPAS.
*
* @return list<string>
*/
private function echtzeitFormate(FinTs $fints): array
{
$formate = $this->echtzeitParameter($fints)?->parameter->getUnterstuetzteSEPADatenformate();
return $formate === null || $formate === []
? $this->sepaFormate($fints)
: array_values($formate);
}
/** @param array<string,mixed> $auftrag */
private static function istEchtzeit(array $auftrag): bool
{
return (int)($auftrag['echtzeit'] ?? 0) === 1;
}
/** /**
* Was nach dem Einreichen des Auftrags noch zu tun ist: auf den * Was nach dem Einreichen des Auftrags noch zu tun ist: auf den
* Namensabgleich warten, ihn bestätigen lassen, die TAN einholen. * Namensabgleich warten, ihn bestätigen lassen, die TAN einholen.
@ -428,6 +487,7 @@ class FinTsUeberweisung
'waehrung' => (string)$auftrag['waehrung'], 'waehrung' => (string)$auftrag['waehrung'],
'verwendungszweck' => (string)$auftrag['verwendungszweck'], 'verwendungszweck' => (string)$auftrag['verwendungszweck'],
'ausfuehrung_am' => $auftrag['ausfuehrung_am'], 'ausfuehrung_am' => $auftrag['ausfuehrung_am'],
'echtzeit' => self::istEchtzeit($auftrag),
]; ];
} }
@ -488,7 +548,13 @@ class FinTsUeberweisung
/** @return array<string,mixed> */ /** @return array<string,mixed> */
private function angenommen(FinTs $fints): array private function angenommen(FinTs $fints): array
{ {
Zahlungsauftrag::alsGesendetVermerken($this->auftragId, 'Von der Bank angenommen.'); $auftrag = Zahlungsauftrag::find($this->auftragId);
$echtzeit = $auftrag !== null && self::istEchtzeit($auftrag);
Zahlungsauftrag::alsGesendetVermerken(
$this->auftragId,
$echtzeit ? 'Von der Bank als Echtzeitüberweisung angenommen.' : 'Von der Bank angenommen.'
);
self::vergessen(); self::vergessen();
try { try {
@ -497,7 +563,12 @@ class FinTsUeberweisung
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts. // Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
} }
return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.']; return [
'status' => 'erfolg',
'meldung' => $echtzeit
? 'Die Echtzeitüberweisung wurde an die Bank übergeben.'
: 'Die Überweisung wurde an die Bank übergeben.',
];
} }
/** /**

View file

@ -18,6 +18,12 @@ namespace Finanzmanager\Services;
* Fassungen im Aufbau gleich (Reihenfolge und Pflichtfelder stimmen überein), * Fassungen im Aufbau gleich (Reihenfolge und Pflichtfelder stimmen überein),
* sie unterscheiden sich nur im Namensraum. Welches Format die Bank annimmt, * sie unterscheiden sich nur im Namensraum. Welches Format die Bank annimmt,
* steht im FinTS-Segment HISPAS – siehe `Services\FinTsUeberweisung`. * steht im FinTS-Segment HISPAS – siehe `Services\FinTsUeberweisung`.
*
* **Echtzeitüberweisung.** SEPA behandelt sie als eigenes Verfahren (SCT Inst):
* In der Nachricht steht zusätzlich die Zahlungsart `LclInstrm/Cd = INST`, und
* die Bank bekommt sie über einen eigenen Geschäftsvorfall (HKIPZ). Das ältere
* DK-Format pain.001.003.03 kennt die Zahlungsart nicht – eine
* Echtzeitüberweisung gibt es deshalb nur im ISO-Format.
*/ */
class SepaXml class SepaXml
{ {
@ -27,6 +33,12 @@ class SepaXml
/** Die Formate, die hier erzeugt werden – in der Reihenfolge des Vorzugs. */ /** Die Formate, die hier erzeugt werden – in der Reihenfolge des Vorzugs. */
private const SCHEMATA = [self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03]; private const SCHEMATA = [self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03];
/** Die Formate, in denen sich eine Echtzeitüberweisung ausdrücken lässt. */
private const SCHEMATA_ECHTZEIT = [self::SCHEMA_PAIN_001_001_03];
/** Zahlungsart (Local Instrument) der Echtzeitüberweisung. */
public const ECHTZEIT = 'INST';
/** /**
* Das Ausführungsdatum für „sofort“. * Das Ausführungsdatum für „sofort“.
* *
@ -44,7 +56,7 @@ class SepaXml
* absender_name: string, absender_iban: string, absender_bic?: string, * absender_name: string, absender_iban: string, absender_bic?: string,
* empfaenger_name: string, empfaenger_iban: string, empfaenger_bic?: string, * empfaenger_name: string, empfaenger_iban: string, empfaenger_bic?: string,
* betrag: float, waehrung?: string, verwendungszweck?: string, * betrag: float, waehrung?: string, verwendungszweck?: string,
* ausfuehrung_am?: ?string * ausfuehrung_am?: ?string, echtzeit?: bool
* } $auftrag * } $auftrag
* @param string $schema Namensraum der Nachricht, siehe `schemaWaehlen()`. * @param string $schema Namensraum der Nachricht, siehe `schemaWaehlen()`.
*/ */
@ -55,12 +67,20 @@ class SepaXml
throw new \InvalidArgumentException('Der Betrag muss größer als null sein.'); throw new \InvalidArgumentException('Der Betrag muss größer als null sein.');
} }
$echtzeit = !empty($auftrag['echtzeit']);
if ($echtzeit && !in_array($schema, self::SCHEMATA_ECHTZEIT, true)) {
throw new \InvalidArgumentException('Eine Echtzeitüberweisung gibt es nur im Format pain.001.001.03.');
}
// Eindeutige Kennungen: Die Bank lehnt eine Nachricht ab, deren Kennung // Eindeutige Kennungen: Die Bank lehnt eine Nachricht ab, deren Kennung
// sie schon kennt. // sie schon kennt.
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6); $kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
$zeitpunkt = date('Y-m-d\TH:i:s'); $zeitpunkt = date('Y-m-d\TH:i:s');
$ausfuehrung = self::ausfuehrungstag((string)($auftrag['ausfuehrung_am'] ?? '')); // Eine Echtzeitüberweisung geht immer sofort hinaus.
$ausfuehrung = $echtzeit
? self::SOFORT
: self::ausfuehrungstag((string)($auftrag['ausfuehrung_am'] ?? ''));
$waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR')); $waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR'));
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140); $zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
@ -100,6 +120,11 @@ class SepaXml
$ebene = $dokument->createElement('SvcLvl'); $ebene = $dokument->createElement('SvcLvl');
$art->appendChild($ebene); $art->appendChild($ebene);
self::kind($dokument, $ebene, 'Cd', 'SEPA'); self::kind($dokument, $ebene, 'Cd', 'SEPA');
if ($echtzeit) {
$zahlungsart = $dokument->createElement('LclInstrm');
$art->appendChild($zahlungsart);
self::kind($dokument, $zahlungsart, 'Cd', self::ECHTZEIT);
}
self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung); self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung);
@ -193,15 +218,18 @@ class SepaXml
* Vorzugsformat; meldet sie nur Formate, die hier nicht erzeugt werden, * Vorzugsformat; meldet sie nur Formate, die hier nicht erzeugt werden,
* kommt `null` zurück. * kommt `null` zurück.
* *
* Für eine Echtzeitüberweisung kommt nur das ISO-Format infrage; führt die
* Bank ausschließlich das DK-Format, ist das Ergebnis ebenfalls `null`.
*
* @param list<string> $unterstuetzt * @param list<string> $unterstuetzt
*/ */
public static function schemaWaehlen(array $unterstuetzt): ?string public static function schemaWaehlen(array $unterstuetzt, bool $echtzeit = false): ?string
{ {
if ($unterstuetzt === []) { if ($unterstuetzt === []) {
return self::SCHEMA_PAIN_001_001_03; return self::SCHEMA_PAIN_001_001_03;
} }
foreach (self::SCHEMATA as $schema) { foreach ($echtzeit ? self::SCHEMATA_ECHTZEIT : self::SCHEMATA as $schema) {
foreach ($unterstuetzt as $format) { foreach ($unterstuetzt as $format) {
if (str_starts_with(trim($format), $schema)) { if (str_starts_with(trim($format), $schema)) {
return $schema; return $schema;

View file

@ -119,6 +119,17 @@ include __DIR__ . '/../layout/kopf.php';
<input type="date" id="ausfuehrung_am" name="ausfuehrung_am" <input type="date" id="ausfuehrung_am" name="ausfuehrung_am"
value="<?= View::h($wert('ausfuehrung_am')) ?>"> value="<?= View::h($wert('ausfuehrung_am')) ?>">
<label class="ankreuzfeld">
<input type="checkbox" id="echtzeit" name="echtzeit" value="1" data-echtzeit
<?= (int)$wert('echtzeit', '0') === 1 ? 'checked' : '' ?>>
Echtzeitüberweisung
</label>
<p class="feldhinweis">
Das Geld ist binnen Sekunden beim Empfänger. Eine Echtzeitüberweisung wird sofort
ausgeführt und hat keinen Ausführungstermin. Bietet die Bank sie nicht an, wird der
Auftrag abgewiesen und bleibt als Entwurf stehen.
</p>
<label for="kategorie_id">Kategorie</label> <label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id"> <select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option> <option value="">– keine –</option>

View file

@ -40,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. Ohne Ausführungstermin geht sie sofort hinaus, mit Termin als
Terminüberweisung. Terminüberweisung, als Echtzeitüberweisung binnen Sekunden.
</p> </p>
<?php if ($auftraege === []): ?> <?php if ($auftraege === []): ?>
@ -66,9 +66,13 @@ include __DIR__ . '/../layout/kopf.php';
<td><?= View::h($auftrag['kontoname']) ?></td> <td><?= View::h($auftrag['kontoname']) ?></td>
<td class="rechts negativ"><?= View::betrag($auftrag['betrag']) ?></td> <td class="rechts negativ"><?= View::betrag($auftrag['betrag']) ?></td>
<td> <td>
<?= $auftrag['ausfuehrung_am'] === null <?php if ((int)($auftrag['echtzeit'] ?? 0) === 1): ?>
? '<span class="nebentext">sofort</span>' <span class="marke" title="Echtzeitüberweisung">Echtzeit</span>
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?> <?php else: ?>
<?= $auftrag['ausfuehrung_am'] === null
? '<span class="nebentext">sofort</span>'
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?>
<?php endif; ?>
</td> </td>
<td> <td>
<?php if ((int)$auftrag['protokolle'] > 0): ?> <?php if ((int)$auftrag['protokolle'] > 0): ?>

View file

@ -0,0 +1,204 @@
<?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\FinTsUeberweisung;
use Finanzmanager\Services\SepaXml;
/**
* Echtzeitüberweisung (SEPA Instant Credit Transfer).
*
* SEPA behandelt sie als eigenes Verfahren: In der pain.001-Nachricht steht die
* Zahlungsart INST, über FinTS geht sie als HKIPZ hinaus, und einen
* Ausführungstermin kennt sie nicht. Geprüft werden das Merkmal am Auftrag, die
* Nachricht und dass ein Sendeversuch als Echtzeitüberweisung protokolliert
* wird. Wie in Test 42 geht der Versuch an eine Adresse auf dem eigenen
* Rechner, auf der niemand lauscht.
*/
class EchtzeitueberweisungTest extends TestCase
{
protected string $testName = 'Test 43: Echtzeitüberweisung';
private int $giro = 0;
private int $zugang = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->dasMerkmalWirdGespeichert();
$this->einTerminVertraegtSichNichtMitEchtzeit();
$this->dieNachrichtTraegtDieZahlungsartInst();
$this->nurDasIsoFormatKenntEchtzeit();
$this->derSendeversuchNenntDieEchtzeitueberweisung();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Echtzeit-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zugang = Bankzugang::anlegen([
'name' => 'Echtzeit-Bank',
'bankname' => 'Echtzeit-Bank AG',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
}
/** @param array<string,mixed> $daten */
private function auftrag(array $daten = []): int
{
return Zahlungsauftrag::anlegen(array_merge([
'konto_id' => $this->giro,
'zugang_id' => $this->zugang,
'empfaenger' => 'Handwerker Schnell',
'iban' => 'DE02120300000000202051',
'betrag' => 250.0,
'echtzeit' => true,
], $daten));
}
private function dasMerkmalWirdGespeichert(): void
{
$id = $this->auftrag();
$this->assertEquals(1, (int)Zahlungsauftrag::find($id)['echtzeit'], 'Die Echtzeitüberweisung ist vermerkt');
$gewoehnlich = $this->auftrag(['echtzeit' => false]);
$this->assertEquals(0, (int)Zahlungsauftrag::find($gewoehnlich)['echtzeit'], 'Ohne Häkchen bleibt es eine gewöhnliche Überweisung');
Zahlungsauftrag::speichern($id, [
'konto_id' => $this->giro,
'empfaenger' => 'Handwerker Schnell',
'iban' => 'DE02120300000000202051',
'betrag' => 250.0,
]);
$this->assertEquals(0, (int)Zahlungsauftrag::find($id)['echtzeit'], 'Abwählen beim Bearbeiten wirkt');
}
private function einTerminVertraegtSichNichtMitEchtzeit(): void
{
$abgewiesen = false;
try {
$this->auftrag(['ausfuehrung_am' => date('Y-m-d', strtotime('+7 days'))]);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Echtzeitüberweisung mit Termin in der Zukunft wird abgewiesen');
$heute = $this->auftrag(['ausfuehrung_am' => date('Y-m-d')]);
$this->assertNull(
Zahlungsauftrag::find($heute)['ausfuehrung_am'],
'Der heutige Tag ist „sofort“ – der Termin fällt weg'
);
}
private function dieNachrichtTraegtDieZahlungsartInst(): void
{
$auftrag = [
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Handwerker Schnell',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 250.0,
'verwendungszweck' => 'Rechnung 4711',
// Ein Termin wird bei Echtzeit nicht übernommen.
'ausfuehrung_am' => '2099-09-05',
'echtzeit' => true,
];
$pfad = static function (string $xml): DOMXPath {
$dokument = new DOMDocument();
$dokument->loadXML($xml);
$xpath = new DOMXPath($dokument);
$xpath->registerNamespace('p', SepaXml::SCHEMA_PAIN_001_001_03);
return $xpath;
};
$xpath = $pfad(SepaXml::ueberweisung($auftrag));
$this->assertEquals(
SepaXml::ECHTZEIT,
$xpath->evaluate('string(//p:PmtInf/p:PmtTpInf/p:LclInstrm/p:Cd)'),
'Die Zahlungsart INST steht in der Nachricht'
);
$this->assertEquals(
'LclInstrm',
$xpath->evaluate('local-name(//p:PmtTpInf/p:SvcLvl/following-sibling::*[1])'),
'Die Zahlungsart folgt unmittelbar auf die Service-Ebene, wie das Schema es verlangt'
);
$this->assertEquals(
SepaXml::SOFORT,
$xpath->evaluate('string(//p:PmtInf/p:ReqdExctnDt)'),
'Eine Echtzeitüberweisung geht immer sofort hinaus'
);
$auftrag['echtzeit'] = false;
$this->assertEquals(
0.0,
$pfad(SepaXml::ueberweisung($auftrag))->evaluate('count(//p:LclInstrm)'),
'Eine gewöhnliche Überweisung trägt keine Zahlungsart'
);
}
private function nurDasIsoFormatKenntEchtzeit(): void
{
$this->assertNull(
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03], true),
'Führt die Bank nur das DK-Format, gibt es keine Echtzeitüberweisung'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4'], true),
'Sonst wird das ISO-Format genommen'
);
$abgewiesen = false;
try {
SepaXml::ueberweisung([
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Handwerker Schnell',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 1.0,
'echtzeit' => true,
], SepaXml::SCHEMA_PAIN_001_003_03);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Echtzeitüberweisung im DK-Format wird nicht erzeugt');
}
private function derSendeversuchNenntDieEchtzeitueberweisung(): 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]
);
$id = $this->auftrag();
$ergebnis = FinTsUeberweisung::senden($id);
FinTsUeberweisung::abbrechen();
$this->assertEquals('fehler', $ergebnis['status'], 'Ohne erreichbare Bank scheitert das Senden');
$this->assertEquals('fehler', Zahlungsauftrag::find($id)['status'], 'Der Auftrag bleibt als fehlgeschlagen stehen');
$eintraege = Bankprotokoll::fuerAuftrag($id);
$this->assert(
$eintraege !== [] && str_contains((string)$eintraege[0]['protokoll'], 'Echtzeitüberweisung'),
'Das Übermittlungsprotokoll nennt die Echtzeitüberweisung'
);
}
}

View file

@ -310,6 +310,21 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung-fehler')).toContainText('IBAN') await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
}) })
test('Die Echtzeitüberweisung sperrt den Ausführungstermin', async ({ page }) => {
await page.goto('/zahlungsverkehr/neu')
await page.fill('#ausfuehrung_am', '2099-09-05')
await page.check('#echtzeit')
// Eine Echtzeitüberweisung geht sofort hinaus – der Termin wird geleert
// und gesperrt, solange sie gewählt ist.
await expect(page.locator('#ausfuehrung_am')).toBeDisabled()
await expect(page.locator('#ausfuehrung_am')).toHaveValue('')
await page.uncheck('#echtzeit')
await expect(page.locator('#ausfuehrung_am')).toBeEnabled()
})
test('Eine Überweisung übernimmt den Empfänger auf Wunsch ins Adressbuch', async ({ page }) => { test('Eine Überweisung übernimmt den Empfänger auf Wunsch ins Adressbuch', async ({ page }) => {
const empfaenger = `E2E-Adressbuch ${Date.now()}` const empfaenger = `E2E-Adressbuch ${Date.now()}`