Zahlungsparser

This commit is contained in:
2026-09-09 16:55:08 +02:00
parent ae13841699
commit 651b6147bf
38 changed files with 3308 additions and 22 deletions
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use PHPUnit\Framework\TestCase;
/**
* Die drei zahlartspezifischen Entscheidungen des Überweisungs-Moduls: Was zählt, wem es gehört, was
* nachgetragen wird. Läuft ohne Datenbank -- die Kandidaten werden hereingereicht, das Modul fragt
* selbst nichts ab.
*/
class BankStatementMatchTest extends TestCase
{
private function participant(
string $identifier,
string $firstname,
string $lastname,
string $purpose = '',
float $amount = 120.0,
float $amountPaid = 0.0,
array $paymentOptions = [],
): EventParticipant {
$participant = new EventParticipant();
$participant->setRawAttributes([
'identifier' => $identifier,
'firstname' => $firstname,
'lastname' => $lastname,
'payment_purpose' => $purpose !== '' ? $purpose : "Sommerlager - Beitrag {$firstname} {$lastname}",
], true);
$participant->amount = new Amount($amount, 'Euro');
$participant->amount_paid = new Amount($amountPaid, 'Euro');
$participant->payment_options = $paymentOptions;
return $participant;
}
private function transaction(
string $purpose,
float $amount = 120.0,
string $payerName = '',
string $payerIban = '',
): BankTransaction {
return new BankTransaction(
paymentDate: CarbonImmutable::create(2026, 9, 9),
amount: new Amount($amount, 'Euro'),
purpose: $purpose,
payerName: $payerName,
payerIban: $payerIban,
rowNumber: 2,
);
}
/** @param array<int, EventParticipant> $participants */
private function candidates(array $participants): Collection
{
return new Collection($participants);
}
public function test_only_credits_are_relevant_for_a_transfer(): void
{
$module = new AccountTransferPaymentModule();
$this->assertTrue($module->isRelevantTransaction($this->transaction('Beitrag', 120.0)));
$this->assertFalse($module->isRelevantTransaction($this->transaction('Erstattung', -120.0)));
}
/** Regel 1: der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug. */
public function test_matches_the_generated_payment_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Lena', 'Kunze'),
]);
$match = $module->matchTransaction($this->transaction('SOMMERLAGER - BEITRAG MAX MEIER'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
$this->assertSame(TransactionMatch::CONFIDENCE_CERTAIN, $match->confidence);
}
/** Regel 2: abgetippter Zweck, aber beide Namen sind noch drin -- auch mit Umlaut. */
public function test_matches_both_names_in_a_retyped_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Jörg', 'Müller')]);
$match = $module->matchTransaction($this->transaction('Beitrag fuer Joerg Mueller, Lager'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 3: Folgezahlung vom Konto, von dem schon einmal etwas kam. */
public function test_matches_a_follow_up_payment_by_known_iban(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', paymentOptions: ['payer_iban' => 'DE02120300000000202051']),
$this->participant('b', 'Lena', 'Kunze'),
]);
// Zweck sagt nichts, IBAN in der Schreibweise der Bank (mit Leerzeichen).
$match = $module->matchTransaction(
$this->transaction('Restzahlung', 60.0, payerIban: 'DE02 1203 0000 0000 2020 51'),
$candidates,
);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 4: nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest. */
public function test_matches_lastname_plus_exact_open_amount(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0),
$this->participant('b', 'Lena', 'Kunze', amount: 120.0),
]);
$match = $module->matchTransaction($this->transaction('Restbetrag Meier', 80.0), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 5: das Konto läuft auf den Namen -- trägt oft, bei Elternkonten aber nicht. */
public function test_matches_payer_name_only_as_uncertain(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$match = $module->matchTransaction($this->transaction('Ueberweisung', 99.0, payerName: 'Max Meier'), $candidates);
$this->assertNotNull($match);
$this->assertSame(TransactionMatch::CONFIDENCE_UNCERTAIN, $match->confidence);
}
/**
* Zwei Namensgleiche: lieber kein Vorschlag als der falsche. In der Prüfansicht wäre die
* Verwechslung nicht zu erkennen und würde durchgewinkt.
*/
public function test_no_suggestion_when_two_candidates_fit(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Max', 'Meier'),
]);
$this->assertNull($module->matchTransaction($this->transaction('Beitrag Max Meier'), $candidates));
}
public function test_no_suggestion_when_nothing_fits(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$this->assertNull($module->matchTransaction($this->transaction('Rechnung 4711', 60.0, payerName: 'ACME GmbH'), $candidates));
}
public function test_records_the_payer_account(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02 1203 0000 0000 2020 51'),
);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Manche Banken lassen den Namen bei Folgezahlungen leer. Der bereits bekannte Kontoinhaber darf
* dadurch nicht verlorengehen -- ohne ihn ist die IBAN für die Erstattung wertlos.
*/
public function test_a_follow_up_without_payer_name_keeps_the_known_owner(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier', paymentOptions: [
'payer_iban' => 'DE02120300000000202051',
'payer_account_owner' => 'Max Meier',
]);
$participant->refund_data = true;
$module->recordTransaction(
$participant,
$this->transaction('Restzahlung', 60.0, payerName: '', payerIban: 'DE02120300000000202051'),
);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Eine IBAN mit Zahlendreher besteht die Prüfziffer nicht und wird nicht übernommen -- sonst
* ginge die Erstattung später an eine fremde Person.
*/
public function test_ignores_an_invalid_iban(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02120300000000202015'),
);
$this->assertSame([], $participant->payment_options);
$this->assertNotTrue($participant->refund_data);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit;
use App\Providers\BankStatementParseProvider;
use App\Support\BankStatementParseException;
use App\ValueObjects\BankStatementRuleset;
use PHPUnit\Framework\TestCase;
/**
* Der Parser -- zahlartneutral und ohne Framework. Getestet werden die Formatfallen, an denen ein
* Bank-Export scheitert: Zeichensatz, Trennzeichen, Zahlformat, fehlende Spalten.
*/
class BankStatementParseTest extends TestCase
{
private function ruleset(array $overrides = []): BankStatementRuleset
{
$base = [
'delimiter' => ';',
'enclosure' => '',
'charset' => 'Windows-1252',
'has_header' => true,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
'columns' => [
'payment_date' => 'Buchungstag',
'purpose' => 'Verwendungszweck',
'amount' => 'Betrag',
'payer_name' => 'Name Zahlungsbeteiligter',
'payer_iban' => 'IBAN Zahlungsbeteiligter',
],
];
return new BankStatementRuleset(
delimiter: $overrides['delimiter'] ?? $base['delimiter'],
enclosure: $overrides['enclosure'] ?? $base['enclosure'],
charset: $overrides['charset'] ?? $base['charset'],
hasHeader: $overrides['has_header'] ?? $base['has_header'],
dateFormat: $overrides['date_format'] ?? $base['date_format'],
decimalSeparator: $overrides['decimal_separator'] ?? $base['decimal_separator'],
thousandsSeparator: $overrides['thousands_separator'] ?? $base['thousands_separator'],
columns: $overrides['columns'] ?? $base['columns'],
);
}
/** Das Format, das die GLS heute liefert: Semikolon, keine Anführungszeichen, CRLF. */
public function test_reads_the_semicolon_export_without_enclosures(): void
{
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset());
$this->assertCount(1, $transactions);
$this->assertSame('2026-09-09', $transactions[0]->paymentDate->format('Y-m-d'));
$this->assertSame(120.0, $transactions[0]->amount->getAmount());
$this->assertSame('Max Meier', $transactions[0]->payerName);
$this->assertSame('DE02120300000000202051', $transactions[0]->payerIban);
$this->assertSame('Sommerlager - Beitrag Max Meier', $transactions[0]->purpose);
// Kopfzeile ist Zeile 1.
$this->assertSame(2, $transactions[0]->rowNumber);
}
public function test_reads_comma_separated_export_with_enclosures(): void
{
$csv = "\"Buchungstag\",\"Verwendungszweck\",\"Betrag\"\n"
. "\"09.09.2026\",\"Beitrag, erste Rate\",\"85,50\"\n";
$transactions = new BankStatementParseProvider()->parse(
$csv,
$this->ruleset([
'delimiter' => ',',
'enclosure' => '"',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]),
);
$this->assertCount(1, $transactions);
// Das Komma im Zweck darf die Zeile nicht zerreißen.
$this->assertSame('Beitrag, erste Rate', $transactions[0]->purpose);
$this->assertSame(85.5, $transactions[0]->amount->getAmount());
}
public function test_converts_windows_1252_umlauts(): void
{
$csv = mb_convert_encoding(
"Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n",
'Windows-1252',
'UTF-8',
);
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
/**
* Der Schutz gegen Doppel-Kodierung: Liefert die Bank entgegen der Einstellung UTF-8, darf aus
* „Müller" kein „Müller" werden. Die Konvertierung würde technisch gelingen -- der Fehler fiele
* erst auf der Rechnung auf.
*/
public function test_does_not_double_encode_utf8_content(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'charset' => 'Windows-1252',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
public function test_parses_german_thousands_and_negative_amounts(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Grosse Zahlung;1.234,56\n"
. "10.09.2026;Rueckbuchung;-56,00\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
// Der Parser filtert nicht -- ob eine Belastung zählt, entscheidet das Zahlungsmodul.
$this->assertCount(2, $transactions);
$this->assertSame(1234.56, $transactions[0]->amount->getAmount());
$this->assertSame(-56.0, $transactions[1]->amount->getAmount());
$this->assertTrue($transactions[0]->isCredit());
$this->assertFalse($transactions[1]->isCredit());
}
/** Summenzeilen und Fußnoten sind keine Umsätze und dürfen den Import nicht abbrechen. */
public function test_skips_rows_without_date_or_amount(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Beitrag;120,00\n"
. ";Summe;;\n"
. "10.09.2026;Ohne Betrag;\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertCount(1, $transactions);
}
/** Der Normalfall bei falsch eingestelltem Ruleset -- die Meldung muss die Spalte nennen. */
public function test_names_the_missing_column(): void
{
$csv = "Buchungstag;Verwendungszweck\n09.09.2026;Beitrag\n";
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/Betrag/');
new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
}
public function test_rejects_a_ruleset_without_required_mapping(): void
{
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/amount/');
new BankStatementParseProvider()->parse(
"Buchungstag;Verwendungszweck\n",
$this->ruleset(['columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck']]),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\PaymentMethod;
use App\ValueObjects\BankStatementRuleset;
use Tests\TestCase;
/**
* Wie sich der app-weite Standard aus config/bankStatement.php und ein Tenant-Override zueinander
* verhalten -- und dass das Ruleset nicht in den Event-Snapshot rutscht.
*
* Braucht den Framework-Kontext (config()), aber keine Datenbank.
*/
class BankStatementRulesetTest extends TestCase
{
public function test_empty_configuration_falls_back_to_the_app_default(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([]);
$this->assertSame(';', $ruleset->delimiter);
$this->assertSame('', $ruleset->enclosure);
$this->assertSame('Windows-1252', $ruleset->charset);
$this->assertSame('Buchungstag', $ruleset->column('payment_date'));
$this->assertSame([], $ruleset->missingRequiredColumns());
}
/**
* Der Override gilt ganz oder gar nicht: Wer die Spalten seiner Bank einträgt, bekommt nicht
* daneben noch die Spaltennamen der GLS untergeschoben und sucht den Fehler an der falschen Stelle.
*/
public function test_a_tenant_override_replaces_the_column_mapping_entirely(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => [
'delimiter' => ',',
'enclosure' => '"',
'columns' => [
'payment_date' => 'Datum',
'purpose' => 'Zweck',
'amount' => 'Summe',
],
],
]);
$this->assertSame(',', $ruleset->delimiter);
$this->assertSame('"', $ruleset->enclosure);
$this->assertSame('Datum', $ruleset->column('payment_date'));
// Nicht aus dem App-Standard ergänzt:
$this->assertNull($ruleset->column('payer_iban'));
}
/** Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular. */
public function test_accepts_the_override_as_json_string(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => json_encode([
'delimiter' => "\t",
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck', 'amount' => 'Summe'],
]),
]);
$this->assertSame("\t", $ruleset->delimiter);
$this->assertSame('Datum', $ruleset->column('payment_date'));
}
public function test_reports_a_missing_required_column(): void
{
$ruleset = BankStatementRuleset::fromConfiguration([
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck'],
]);
$this->assertSame(['amount'], $ruleset->missingRequiredColumns());
}
/**
* Das Kontoauszug-Format beschreibt die Bank, nicht die Zusage an die Teilnehmenden. Im
* Event-Snapshot eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr
* importieren -- IBAN und Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
*/
public function test_the_ruleset_is_stripped_from_the_event_snapshot(): void
{
$configuration = [
'account_owner' => 'Kasse',
'iban' => 'DE02120300000000202051',
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => ['delimiter' => ','],
];
$snapshot = PaymentMethod::stripTenantScopedOptions(
PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
$configuration,
);
$this->assertArrayNotHasKey(AccountTransferPaymentModule::OPTION_STATEMENT_RULESET, $snapshot);
$this->assertSame('Kasse', $snapshot['account_owner']);
$this->assertSame('DE02120300000000202051', $snapshot['iban']);
}
public function test_the_ruleset_option_is_the_only_tenant_scoped_one(): void
{
$this->assertSame(
[AccountTransferPaymentModule::OPTION_STATEMENT_RULESET],
new AccountTransferPaymentModule()->tenantScopedOptionKeys(),
);
}
}
+21 -2
View File
@@ -52,14 +52,33 @@ class EventPaymentModuleRegistryTest extends TestCase
public function test_participant_options_per_module(): void
{
// Überweisung: keine payer-seitigen Eingaben.
$this->assertSame([], (new AccountTransferPaymentModule())->getParticipantOptions());
// Überweisung: keine payer-seitigen Eingaben im Anmeldeformular.
$this->assertSame([], (new AccountTransferPaymentModule())->participantInputOptions());
// Sonstiges (Barzahlung vor Ort): ebenfalls keine payer-seitigen Eingaben -- die Zahlungsart
// beschreibt sich allein über den vom Veranstalter gepflegten Freitext.
$this->assertSame([], (new UndefinedPaymentModule())->getParticipantOptions());
}
public function test_system_participant_options_stay_in_the_schema(): void
{
$module = new AccountTransferPaymentModule();
// Die Zahler-Konto-Felder trägt der Zahlungsimport nach, gefragt wird beim Anmelden nicht
// danach. Im Schema müssen sie trotzdem stehen -- sonst wirft sanitizeParticipantOptions()
// sie beim nächsten Durchlauf als unbekannte Schlüssel weg.
$names = array_column($module->getParticipantOptions(), 'name');
$this->assertSame(['payer_account_owner', 'payer_iban'], $names);
$this->assertSame(
['payer_iban' => 'DE02120300000000202051'],
$module->sanitizeParticipantOptions(['payer_iban' => 'DE02120300000000202051', 'evil' => 'x'])
);
// Kein Pflichtfeld -- eine Anmeldung ohne Zahlungseingang bleibt vollständig.
$this->assertTrue($module->participantOptionsComplete([]));
}
public function test_participant_option_helpers(): void
{
// Anonymes Modul mit einer Pflicht-Teilnehmereingabe.