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:
commit
8bd61f55a3
13 changed files with 429 additions and 23 deletions
20
Changes.md
20
Changes.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
migrations/019_echtzeitueberweisung.sql
Normal file
11
migrations/019_echtzeitueberweisung.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<?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): ?>
|
||||
|
|
|
|||
204
tests/43_EchtzeitueberweisungTest.php
Normal file
204
tests/43_EchtzeitueberweisungTest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}`
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue