Fix #119: Empfänger bei Kartenverfügungen aus dem Verwendungszweck lesen

Die comdirect liefert bei Zahlungen mit der Visa-Debitkarte (GVC 700) kein
Namensfeld; der Händler steht nur zwischen „KARTENVERFÜGUNG“ und „KARTE NR.“.
Der Abruf leitet ihn ab, ohne den Fingerabdruck zu ändern; ein erneuter Abruf
und Migration 027 tragen ihn bei vorhandenen Umsätzen und Buchungen nach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-06 18:31:50 +02:00
parent 8b245ad769
commit 69af1af5eb
7 changed files with 245 additions and 14 deletions

View file

@ -1,5 +1,13 @@
# Änderungen # Ä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) ## 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. - Anlass (#113): Bei offenem Status (Rückmeldung 3045) blieb nur der Blick in den Kontoumsatz.

View file

@ -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 **Kontonummer** wieder; eine später nachgelieferte IBAN wird am vorhandenen Eintrag
nachgetragen, statt einen zweiten anzulegen. 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 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: 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 nicht im Kontoblatt, nicht in der Liste, und beim nächsten Abruf als schon vorhanden

View file

@ -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';

View file

@ -124,9 +124,11 @@ class Bankumsatz
} }
/** /**
* Trägt IBAN und Bank der Gegenseite bei einem bereits bekannten Umsatz * Trägt Empfänger, IBAN und Bank der Gegenseite bei einem bereits bekannten
* nach. Ältere Abrufe haben sie noch nicht gespeichert; der erneute Abruf * Umsatz nach. Ältere Abrufe haben sie noch nicht gespeichert – den
* liefert sie, und der Fingerabdruck erkennt den Umsatz nur als bekannt. * 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 * 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 * Umsatz an einer Buchung, bekommt auch sie die Angaben, soweit sie dort
* fehlen. * fehlen.
@ -135,18 +137,20 @@ class Bankumsatz
*/ */
public static function ergaenzen(string $externId, array $daten): void public static function ergaenzen(string $externId, array $daten): void
{ {
$iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34); $empfaenger = mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255);
$bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128); $iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34);
if ($iban === '' && $bankname === '') { $bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128);
if ($empfaenger === '' && $iban === '' && $bankname === '') {
return; return;
} }
Database::execute( Database::execute(
"UPDATE bank_umsaetze "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) gegen_bankname = IF(gegen_bankname = '', ?, gegen_bankname)
WHERE benutzer_id = ? AND extern_id = ?", WHERE benutzer_id = ? AND extern_id = ?",
[$iban, $bankname, Mandant::id(), $externId] [$empfaenger, $iban, $bankname, Mandant::id(), $externId]
); );
$buchungId = Database::fetchValue( $buchungId = Database::fetchValue(
@ -154,19 +158,20 @@ class Bankumsatz
[Mandant::id(), $externId] [Mandant::id(), $externId]
); );
if ($buchungId !== null && $buchungId !== false) { 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. */ /** Füllt Empfänger, IBAN und Bank einer Buchung, soweit sie dort noch leer sind. */
private static function herkunftUebertragen(int $buchungId, string $iban, string $bankname): void private static function herkunftUebertragen(int $buchungId, string $empfaenger, string $iban, string $bankname): void
{ {
Database::execute( Database::execute(
"UPDATE buchungen "UPDATE buchungen
SET iban = IF(iban = '', ?, iban), SET empfaenger = IF(empfaenger = '', ?, empfaenger),
iban = IF(iban = '', ?, iban),
bankname = IF(bankname = '', ?, bankname) bankname = IF(bankname = '', ?, bankname)
WHERE id = ? AND benutzer_id = ?", 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) { if ($umsatz !== null) {
self::herkunftUebertragen( self::herkunftUebertragen(
$buchungId, $buchungId,
'',
(string)($umsatz['gegen_iban'] ?? ''), (string)($umsatz['gegen_iban'] ?? ''),
(string)($umsatz['gegen_bankname'] ?? '') (string)($umsatz['gegen_bankname'] ?? '')
); );

View file

@ -417,6 +417,12 @@ class FinTsAbruf
$empfaenger $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([ $id = Bankumsatz::anlegen([
'zugang_id' => (int)$this->zugang['id'], 'zugang_id' => (int)$this->zugang['id'],
'konto_id' => $bankkonto['konto_id'], 'konto_id' => $bankkonto['konto_id'],
@ -434,7 +440,7 @@ class FinTsAbruf
'iban' => $bankkonto['iban'], 'iban' => $bankkonto['iban'],
'eref' => $umsatz->getEndToEndID(), 'eref' => $umsatz->getEndToEndID(),
'code' => $umsatz->getBookingCode(), 'code' => $umsatz->getBookingCode(),
'name' => $empfaenger, 'name' => trim($umsatz->getName()),
'zweck' => $zweck, 'zweck' => $zweck,
], JSON_UNESCAPED_UNICODE), ], JSON_UNESCAPED_UNICODE),
]); ]);

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Empfänger eines Bankumsatzes, wenn die Bank ihn nicht als eigenes Feld liefert.
*
* Die comdirect füllt bei Kartenverfügungen der Visa-Debitkarte (GVC 700) das
* Namensfeld (?32/?33) nicht. Der Händler steht nur im Verwendungszweck,
* zwischen dem Buchungstext und der Kartennummer:
*
* KARTENVERFÜGUNGTINDIE, EZPLM.COM USKARTE NR. 4871 78XX XXXX 9119…
*
* Im Online-Banking zeigt die Bank genau diesen Teil als Empfänger an.
*/
final class UmsatzEmpfaenger
{
private const KARTENVERFUEGUNG = '/^\s*KARTENVERF(?:Ü|UE)GUNG\s*(.+?)\s*KARTE NR\./iu';
/** Liefert den Empfänger aus dem Verwendungszweck oder '' , wenn keiner erkennbar ist. */
public static function ausVerwendungszweck(string $zweck): string
{
if (preg_match(self::KARTENVERFUEGUNG, $zweck, $m) !== 1) {
return '';
}
return trim(preg_replace('/\s+/u', ' ', $m[1]) ?? $m[1]);
}
}

View file

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\UmsatzEmpfaenger;
/**
* Empfänger bei Kartenverfügungen (#119).
*
* Die comdirect liefert bei Zahlungen mit der Visa-Debitkarte kein
* Namensfeld; der Händler steht nur im Verwendungszweck. Geprüft wird, dass
* er dort herausgelesen wird, dass andere Umsätze unberührt bleiben und dass
* ein erneuter Abruf den Empfänger bei bereits übernommenen Umsätzen und
* ihren Buchungen nachträgt.
*/
class EmpfaengerKartenverfuegungTest extends TestCase
{
protected string $testName = 'Test 58: Empfänger bei Kartenverfügungen';
private const TINDIE = 'KARTENVERFÜGUNGTINDIE, EZPLM.COM USKARTE NR. 4871 78XX XXXX 9119'
. 'KARTENZAHLUNGCOMDIRECT VISA-DEBITKARTE2026-09-30 00:00:00ORIGINALBETRAG: 28,19 USD'
. 'Ref. 6V2C29HM13Z7LAYO/10934';
private int $giro = 0;
private int $zugang = 0;
public function run(): void
{
$this->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<string,mixed> $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'
);
}
}