Merge pull request 'Fix #71: Echtzeitüberweisung im Überweisungsdialog' (#72) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/72
This commit is contained in:
fds2610 2026-09-25 15:22:39 +02:00
commit 8bd61f55a3
13 changed files with 429 additions and 23 deletions

View file

@ -1,5 +1,25 @@
# Ä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)
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.
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
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
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.

View file

@ -131,6 +131,17 @@ Fehlgeschlagene tragen die Meldung der Bank daneben.
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
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
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
@ -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
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.
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.
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
// stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn
// „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'),
'verwendungszweck' => $this->post('verwendungszweck'),
'ausfuehrung_am' => $this->post('ausfuehrung_am'),
'echtzeit' => isset($_POST['echtzeit']),
'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'),
];

View file

@ -11,7 +11,9 @@ use Finanzmanager\Mandant;
* Überweisungen: erfasst, geprüft, über FinTS abgesendet.
*
* 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.
*/
class Zahlungsauftrag
@ -63,12 +65,12 @@ class Zahlungsauftrag
Database::execute(
'INSERT INTO zahlungsauftraege
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
verwendungszweck, ausfuehrung_am, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
$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(
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
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 = ?',
[
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
$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.');
}
// 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 =>
($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
// darüber steht, würde die Bank ohnehin abschneiden.
'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),
'klasse_id' => $id($daten['klasse_id'] ?? null),
];

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\SendSEPARealtimeTransfer;
use Fhp\Action\SendSEPATransfer;
use Fhp\BaseAction;
use Fhp\FinTs;
@ -14,6 +15,8 @@ use Fhp\Model\VopConfirmationRequest;
use Fhp\Model\VopVerificationResult;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Fhp\Segment\IPZ\HIIPZSv1;
use Fhp\Segment\IPZ\HIIPZSv2;
use Fhp\Segment\SPA\HISPAS;
use Finanzmanager\Auth;
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
* die Bank nicht prüfen, wird zuerst gefragt – mit dem Text, den die Bank dazu
* 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
{
@ -185,7 +195,8 @@ class FinTsUeberweisung
$auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag !== null) {
$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,
$auftrag['empfaenger'],
$auftrag['iban'],
@ -307,17 +318,29 @@ class FinTsUeberweisung
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
}
$formate = $this->sepaFormate($fints);
$schema = SepaXml::schemaWaehlen($formate);
$echtzeit = self::istEchtzeit($auftrag);
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) {
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)
);
}
$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);
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
* Namensabgleich warten, ihn bestätigen lassen, die TAN einholen.
@ -428,6 +487,7 @@ class FinTsUeberweisung
'waehrung' => (string)$auftrag['waehrung'],
'verwendungszweck' => (string)$auftrag['verwendungszweck'],
'ausfuehrung_am' => $auftrag['ausfuehrung_am'],
'echtzeit' => self::istEchtzeit($auftrag),
];
}
@ -488,7 +548,13 @@ class FinTsUeberweisung
/** @return array<string,mixed> */
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();
try {
@ -497,7 +563,12 @@ class FinTsUeberweisung
// 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),
* sie unterscheiden sich nur im Namensraum. Welches Format die Bank annimmt,
* 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
{
@ -27,6 +33,12 @@ class SepaXml
/** 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];
/** 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“.
*
@ -44,7 +56,7 @@ class SepaXml
* absender_name: string, absender_iban: string, absender_bic?: string,
* empfaenger_name: string, empfaenger_iban: string, empfaenger_bic?: string,
* betrag: float, waehrung?: string, verwendungszweck?: string,
* ausfuehrung_am?: ?string
* ausfuehrung_am?: ?string, echtzeit?: bool
* } $auftrag
* @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.');
}
$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
// sie schon kennt.
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
$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'));
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
@ -100,6 +120,11 @@ class SepaXml
$ebene = $dokument->createElement('SvcLvl');
$art->appendChild($ebene);
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);
@ -193,15 +218,18 @@ class SepaXml
* Vorzugsformat; meldet sie nur Formate, die hier nicht erzeugt werden,
* 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
*/
public static function schemaWaehlen(array $unterstuetzt): ?string
public static function schemaWaehlen(array $unterstuetzt, bool $echtzeit = false): ?string
{
if ($unterstuetzt === []) {
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) {
if (str_starts_with(trim($format), $schema)) {
return $schema;

View file

@ -119,6 +119,17 @@ include __DIR__ . '/../layout/kopf.php';
<input type="date" id="ausfuehrung_am" name="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>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>

View file

@ -40,7 +40,7 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis">
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
Terminüberweisung.
Terminüberweisung, als Echtzeitüberweisung binnen Sekunden.
</p>
<?php if ($auftraege === []): ?>
@ -66,9 +66,13 @@ include __DIR__ . '/../layout/kopf.php';
<td><?= View::h($auftrag['kontoname']) ?></td>
<td class="rechts negativ"><?= View::betrag($auftrag['betrag']) ?></td>
<td>
<?= $auftrag['ausfuehrung_am'] === null
? '<span class="nebentext">sofort</span>'
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?>
<?php if ((int)($auftrag['echtzeit'] ?? 0) === 1): ?>
<span class="marke" title="Echtzeitüberweisung">Echtzeit</span>
<?php else: ?>
<?= $auftrag['ausfuehrung_am'] === null
? '<span class="nebentext">sofort</span>'
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?>
<?php endif; ?>
</td>
<td>
<?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')
})
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 }) => {
const empfaenger = `E2E-Adressbuch ${Date.now()}`