diff --git a/Changes.md b/Changes.md index bfee887..8ecde5d 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ # Änderungen +## Kontoumsätze: Empfänger bei Kartenverfügungen (06.10.2026) + +- Anlass (#119): Bei Zahlungen mit der comdirect Visa-Debitkarte (Kartenverfügung, GVC 700) blieb der Empfänger leer – die Bank liefert dafür kein Namensfeld (?32/?33), der Händler steht nur im Verwendungszweck zwischen „KARTENVERFÜGUNG“ und „KARTE NR.“. +- Fehlt der Name, liest der FinTS-Abruf ihn dort heraus (`Services/UmsatzEmpfaenger`, neu). Der Fingerabdruck des Umsatzes bleibt unverändert, bereits abgerufene Umsätze kommen nicht doppelt herein. +- Ein erneuter Abruf trägt den Empfänger bei bekannten Umsätzen und ihren Buchungen nach, wenn er dort leer ist (`Bankumsatz::ergaenzen()`). +- Migration `027_empfaenger_kartenverfuegung.sql` zieht vorhandene Kartenverfügungen und ihre Buchungen nach. +- Test: `tests/58_EmpfaengerKartenverfuegungTest.php`. + ## Echtzeitüberweisung: Status bei der Bank nachfragen (06.10.2026) - Anlass (#113): Bei offenem Status (Rückmeldung 3045) blieb nur der Blick in den Kontoumsatz. diff --git a/docs/banking.md b/docs/banking.md index b3f0863..b7b0b7d 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -409,6 +409,12 @@ Umsatz ein zweites Mal herein. Aus demselben Grund findet ein Bankkonto sich üb **Kontonummer** wieder; eine später nachgelieferte IBAN wird am vorhandenen Eintrag nachgetragen, statt einen zweiten anzulegen. +Bei Kartenverfügungen liefert die Comdirect kein Namensfeld; der Händler steht nur im +Verwendungszweck zwischen „KARTENVERFÜGUNG“ und „KARTE NR.“. Fehlt der Name, liest der +Abruf ihn dort heraus (`UmsatzEmpfaenger`) – erst nach dem Fingerabdruck, der damit +derselbe bleibt. Ein erneuter Abruf trägt so gewonnene Empfänger bei bekannten Umsätzen +und ihren Buchungen nach, wo sie noch leer sind. + Verliert ein Umsatz seine Buchung – jemand löscht sie im Kontoblatt –, kehrt er in die Vorschlagsliste zurück. Bliebe er auf *übernommen* stehen, wäre er nirgends mehr zu sehen: nicht im Kontoblatt, nicht in der Liste, und beim nächsten Abruf als schon vorhanden diff --git a/migrations/027_empfaenger_kartenverfuegung.sql b/migrations/027_empfaenger_kartenverfuegung.sql new file mode 100644 index 0000000..8fb325e --- /dev/null +++ b/migrations/027_empfaenger_kartenverfuegung.sql @@ -0,0 +1,23 @@ +-- Empfänger bei Kartenverfügungen nachtragen (#119). +-- +-- Die comdirect liefert bei Kartenverfügungen der Visa-Debitkarte kein +-- Namensfeld; der Händler steht nur im Verwendungszweck zwischen +-- „KARTENVERFÜGUNG“ und „KARTE NR.“. Neue Abrufe leiten ihn ab +-- (UmsatzEmpfaenger), hier werden die bereits abgerufenen Umsätze und die +-- daraus übernommenen Buchungen nachgezogen – nur, wo der Empfänger leer ist. +-- +-- Idempotent: Nach dem ersten Lauf ist der Empfänger nicht mehr leer. + +UPDATE bank_umsaetze + SET empfaenger = LEFT(TRIM(REGEXP_REPLACE( + REGEXP_REPLACE(verwendungszweck, '(?s)^\\s*KARTENVERF(Ü|UE)GUNG\\s*(.+?)\\s*KARTE NR\\..*$', '\\2'), + '\\s+', ' ')), 255) + WHERE empfaenger = '' + AND verwendungszweck REGEXP '^\\s*KARTENVERF(Ü|UE)GUNG\\s*(.+?)\\s*KARTE NR\\.'; + +UPDATE buchungen b + JOIN bank_umsaetze u ON u.buchung_id = b.id AND u.benutzer_id = b.benutzer_id + SET b.empfaenger = u.empfaenger + WHERE b.empfaenger = '' + AND u.empfaenger <> '' + AND u.verwendungszweck REGEXP '^\\s*KARTENVERF(Ü|UE)GUNG'; diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 019b717..e3072e1 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -124,9 +124,11 @@ class Bankumsatz } /** - * Trägt IBAN und Bank der Gegenseite bei einem bereits bekannten Umsatz - * nach. Ältere Abrufe haben sie noch nicht gespeichert; der erneute Abruf - * liefert sie, und der Fingerabdruck erkennt den Umsatz nur als bekannt. + * Trägt Empfänger, IBAN und Bank der Gegenseite bei einem bereits bekannten + * Umsatz nach. Ältere Abrufe haben sie noch nicht gespeichert – den + * Empfänger etwa bei Kartenverfügungen, die ihn nur im Verwendungszweck + * nennen (#119); der erneute Abruf liefert sie, und der Fingerabdruck + * erkennt den Umsatz nur als bekannt. * Nur leere Felder werden gefüllt – was schon dasteht, bleibt. Hängt der * Umsatz an einer Buchung, bekommt auch sie die Angaben, soweit sie dort * fehlen. @@ -135,18 +137,20 @@ class Bankumsatz */ public static function ergaenzen(string $externId, array $daten): void { - $iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34); - $bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128); - if ($iban === '' && $bankname === '') { + $empfaenger = mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255); + $iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34); + $bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128); + if ($empfaenger === '' && $iban === '' && $bankname === '') { return; } Database::execute( "UPDATE bank_umsaetze - SET gegen_iban = IF(gegen_iban = '', ?, gegen_iban), + SET empfaenger = IF(empfaenger = '', ?, empfaenger), + gegen_iban = IF(gegen_iban = '', ?, gegen_iban), gegen_bankname = IF(gegen_bankname = '', ?, gegen_bankname) WHERE benutzer_id = ? AND extern_id = ?", - [$iban, $bankname, Mandant::id(), $externId] + [$empfaenger, $iban, $bankname, Mandant::id(), $externId] ); $buchungId = Database::fetchValue( @@ -154,19 +158,20 @@ class Bankumsatz [Mandant::id(), $externId] ); if ($buchungId !== null && $buchungId !== false) { - self::herkunftUebertragen((int)$buchungId, $iban, $bankname); + self::herkunftUebertragen((int)$buchungId, $empfaenger, $iban, $bankname); } } - /** Füllt IBAN und Bank einer Buchung, soweit sie dort noch leer sind. */ - private static function herkunftUebertragen(int $buchungId, string $iban, string $bankname): void + /** Füllt Empfänger, IBAN und Bank einer Buchung, soweit sie dort noch leer sind. */ + private static function herkunftUebertragen(int $buchungId, string $empfaenger, string $iban, string $bankname): void { Database::execute( "UPDATE buchungen - SET iban = IF(iban = '', ?, iban), + SET empfaenger = IF(empfaenger = '', ?, empfaenger), + iban = IF(iban = '', ?, iban), bankname = IF(bankname = '', ?, bankname) WHERE id = ? AND benutzer_id = ?", - [$iban, $bankname, $buchungId, Mandant::id()] + [$empfaenger, $iban, $bankname, $buchungId, Mandant::id()] ); } @@ -757,6 +762,7 @@ class Bankumsatz if ($umsatz !== null) { self::herkunftUebertragen( $buchungId, + '', (string)($umsatz['gegen_iban'] ?? ''), (string)($umsatz['gegen_bankname'] ?? '') ); diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 03bb73c..0d113d9 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -417,6 +417,12 @@ class FinTsAbruf $empfaenger ); + // Erst nach dem Fingerabdruck: Er bleibt so bei bereits + // abgerufenen Umsätzen gleich, sonst kämen sie doppelt herein. + if ($empfaenger === '') { + $empfaenger = UmsatzEmpfaenger::ausVerwendungszweck($zweck); + } + $id = Bankumsatz::anlegen([ 'zugang_id' => (int)$this->zugang['id'], 'konto_id' => $bankkonto['konto_id'], @@ -434,7 +440,7 @@ class FinTsAbruf 'iban' => $bankkonto['iban'], 'eref' => $umsatz->getEndToEndID(), 'code' => $umsatz->getBookingCode(), - 'name' => $empfaenger, + 'name' => trim($umsatz->getName()), 'zweck' => $zweck, ], JSON_UNESCAPED_UNICODE), ]); diff --git a/src/Services/UmsatzEmpfaenger.php b/src/Services/UmsatzEmpfaenger.php new file mode 100644 index 0000000..240ed0e --- /dev/null +++ b/src/Services/UmsatzEmpfaenger.php @@ -0,0 +1,31 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->haendlerWirdAusDemVerwendungszweckGelesen(); + $this->andereUmsaetzeBleibenOhneEmpfaenger(); + $this->erneuterAbrufTraegtDenEmpfaengerNach(); + $this->vorhandenerEmpfaengerBleibt(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Karten-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zugang = Bankzugang::anlegen([ + 'name' => 'Kartenbank', 'bankname' => 'Kartenbank AG', 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, + ]); + } + + private function haendlerWirdAusDemVerwendungszweckGelesen(): void + { + $this->assertEquals( + 'TINDIE, EZPLM.COM US', + UmsatzEmpfaenger::ausVerwendungszweck(self::TINDIE), + 'Der Händler steht zwischen Buchungstext und Kartennummer' + ); + $this->assertEquals( + 'ANTHROPIC* CLAUDE SUB, ANTHROPIC.COM US', + UmsatzEmpfaenger::ausVerwendungszweck( + 'KARTENVERFÜGUNGANTHROPIC* CLAUDE SUB, ANTHROPIC.COM USKARTE NR. 4871 78XX XXXX 9119' + . 'KARTENZAHLUNGCOMDIRECT VISA-DEBITKARTE2026-09-24 20:46:00' + ), + 'Auch ein Händlername über zwei Zeilen' + ); + $this->assertEquals( + 'BAECKEREI MUELLER', + UmsatzEmpfaenger::ausVerwendungszweck('KARTENVERFUEGUNG BAECKEREI MUELLER KARTE NR. 1234'), + 'Auch in Umschrift' + ); + } + + private function andereUmsaetzeBleibenOhneEmpfaenger(): void + { + $this->assertEquals( + '', + UmsatzEmpfaenger::ausVerwendungszweck( + 'KONTOABSCHLUSSABSCHLUSS ZINSEN KTO 847626905EURVON 30.06.2026 BIS 30.09.2026' + ), + 'Ein Kontoabschluss hat keinen Empfänger' + ); + $this->assertEquals( + '', + UmsatzEmpfaenger::ausVerwendungszweck( + 'LASTSCHRIFT / BELASTUNGDB FERNVERKEHR AG, FRANKFURT DEKARTE NR. 4871 78XX XXXX 9119' + ), + 'Eine Lastschrift bringt ihren Empfänger selbst mit' + ); + } + + /** @param array $daten */ + private function umsatz(string $externId, array $daten = []): ?int + { + return Bankumsatz::anlegen(array_merge([ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->giro, + 'extern_id' => $externId, + 'buchungstag' => '2026-10-02', + 'wertstellung' => '2026-10-02', + 'betrag' => -25.48, + 'empfaenger' => '', + 'verwendungszweck' => self::TINDIE, + ], $daten)); + } + + private function erneuterAbrufTraegtDenEmpfaengerNach(): void + { + $externId = 'karte-' . bin2hex(random_bytes(4)); + $umsatzId = (int)$this->umsatz($externId); + $buchungId = Bankumsatz::uebernehmen($umsatzId); + $this->assertEquals('', Buchung::find($buchungId)['empfaenger'], 'Vorher ohne Empfänger übernommen'); + + $wieder = $this->umsatz($externId, ['empfaenger' => 'TINDIE, EZPLM.COM US']); + + $this->assertNull($wieder, 'Der erneute Abruf legt keinen zweiten Umsatz an'); + $this->assertEquals( + 'TINDIE, EZPLM.COM US', + Bankumsatz::find($umsatzId)['empfaenger'], + 'Der Umsatz bekommt den Empfänger nachgetragen' + ); + $this->assertEquals( + 'TINDIE, EZPLM.COM US', + Buchung::find($buchungId)['empfaenger'], + 'Ebenso die übernommene Buchung' + ); + } + + private function vorhandenerEmpfaengerBleibt(): void + { + $externId = 'karte-' . bin2hex(random_bytes(4)); + $umsatzId = (int)$this->umsatz($externId, ['empfaenger' => 'Tindie', 'betrag' => -12.30]); + $buchungId = Bankumsatz::uebernehmen($umsatzId); + Database::execute( + "UPDATE buchungen SET empfaenger = 'Tindie Elektronik' WHERE id = ?", + [$buchungId] + ); + + $this->umsatz($externId, ['empfaenger' => 'TINDIE, EZPLM.COM US', 'betrag' => -12.30]); + + $this->assertEquals('Tindie', Bankumsatz::find($umsatzId)['empfaenger'], 'Der Umsatz behält seinen Empfänger'); + $this->assertEquals( + 'Tindie Elektronik', + Buchung::find($buchungId)['empfaenger'], + 'Die Buchung behält den von Hand gesetzten Empfänger' + ); + } +}