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
|
# Ä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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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): ?>
|
||||||
|
|
|
||||||
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')
|
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()}`
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue