SepaXml erzeugt jetzt zusätzlich pain.001.001.09 (Dt-Element für den Ausführungstermin, BICFI statt BIC, UETR je Zahlung) für Banken, die das ältere pain.001.001.03 nicht mehr führen. FinTsUeberweisung fängt außerdem die UnsupportedException ab, die php-fints wirft, wenn die Bank Namensabgleich-Volltreffer und TAN-Anfrage in einem Antwortsatz kombiniert, und lässt den vorhandenen automatischen Volltreffer-Namensabgleich den Rest erledigen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
209 lines
7.9 KiB
PHP
209 lines
7.9 KiB
PHP
<?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'
|
||
);
|
||
$this->assertEquals(
|
||
SepaXml::SCHEMA_PAIN_001_001_09,
|
||
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03, SepaXml::SCHEMA_PAIN_001_001_09], true),
|
||
'Führt die Bank beide ISO-Fassungen, gewinnt für Echtzeit ebenfalls die neuere'
|
||
);
|
||
|
||
$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'
|
||
);
|
||
}
|
||
}
|