Zahlungsparser
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
|
||||
use App\Enumerations\EatingHabit;
|
||||
use App\Enumerations\EfzStatus;
|
||||
use App\Enumerations\FirstAidPermission;
|
||||
use App\Enumerations\ParticipationType;
|
||||
use App\Enumerations\SwimmingPermission;
|
||||
use App\Mail\ParticipantPaymentMails\ParticipantPaymentMissingPaymentMail;
|
||||
use App\Mail\ParticipantPaymentMails\ParticipantPaymentPaidMail;
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\RelationModels\EventPaymentMethods;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Der Zahlungsimport von der hochgeladenen Datei bis zur gebuchten Zahlung.
|
||||
*/
|
||||
class BankStatementImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
private Event $event;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Mail::fake();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
|
||||
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
|
||||
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
|
||||
]);
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||
|
||||
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
|
||||
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
|
||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
|
||||
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
|
||||
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
|
||||
|
||||
$this->event = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-1', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
|
||||
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
|
||||
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
|
||||
|
||||
AvailablePaymentMethod::create([
|
||||
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'name' => 'Überweisung', 'active' => true, 'configuration' => $configuration,
|
||||
]);
|
||||
|
||||
EventPaymentMethods::create([
|
||||
'event_id' => $this->event->id,
|
||||
'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
|
||||
private function participant(
|
||||
string $identifier,
|
||||
string $firstname,
|
||||
string $lastname,
|
||||
float $amount = 120.0,
|
||||
float $amountPaid = 0.0,
|
||||
?string $lastPaymentDate = null,
|
||||
?string $unregisteredAt = null,
|
||||
): EventParticipant {
|
||||
return EventParticipant::create([
|
||||
'tenant' => $this->tenant->slug, 'event_id' => $this->event->id, 'identifier' => $identifier,
|
||||
'firstname' => $firstname, 'lastname' => $lastname,
|
||||
'participation_type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '00000', 'city' => 'Stadt',
|
||||
'email_1' => strtolower($firstname) . '@example.com', 'phone_1' => '123',
|
||||
'arrival_date' => '2026-08-01', 'departure_date' => '2026-08-05',
|
||||
'arrival_eating' => 0, 'departure_eating' => 0,
|
||||
'amount' => $amount, 'amount_paid' => $amountPaid,
|
||||
'payment_purpose' => "Sommerlager - Beitrag {$firstname} {$lastname}",
|
||||
'efz_status' => EfzStatus::EFZ_STATUS_NOT_CHECKED,
|
||||
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
|
||||
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
|
||||
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
|
||||
'last_payment_date' => $lastPaymentDate,
|
||||
'unregistered_at' => $unregisteredAt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function upload(string $csv, string $name = 'Umsaetze.csv'): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent($name, $csv);
|
||||
}
|
||||
|
||||
private function parse(UploadedFile $file): \App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementResponse
|
||||
{
|
||||
return new ParseBankStatementCommand(new ParseBankStatementRequest(
|
||||
event: $this->event,
|
||||
file: $file,
|
||||
configuration: (new \App\Repositories\PaymentMethodRepository())
|
||||
->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION),
|
||||
))->execute();
|
||||
}
|
||||
|
||||
public function test_parse_suggests_the_matching_participant(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier');
|
||||
$this->participant('p-2', 'Lena', 'Kunze');
|
||||
|
||||
$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"
|
||||
. "09.09.2026;09.09.2026;ACME GmbH;DE02120300000000202051;Rechnung 4711;60,00\r\n"
|
||||
// Belastung: keine Zahlung an die Aktion, taucht gar nicht erst auf.
|
||||
. "09.09.2026;09.09.2026;Zeltplatz;DE02120300000000202051;Miete;-300,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertCount(2, $response->rows);
|
||||
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
|
||||
$this->assertSame('sicher', $response->rows[0]['confidence']);
|
||||
$this->assertNull($response->rows[1]['suggestedIdentifier']);
|
||||
|
||||
// Die Auswahlliste trägt alle Anmeldungen, auch die ohne Vorschlag -- nach Nachname sortiert,
|
||||
// damit sich in einem Lager mit achtzig Namen etwas finden lässt.
|
||||
$this->assertSame(
|
||||
['Kunze, Lena', 'Meier, Max'],
|
||||
array_column($response->participants, 'name'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer den Beitrag überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Bliebe
|
||||
* die Anmeldung hier draußen, wäre der Eingang nicht zuordenbar und das Geld läge unbemerkt auf
|
||||
* dem Konto -- erstatten lässt sich erst, was erfasst ist.
|
||||
*/
|
||||
public function test_parse_includes_signed_off_participants(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', unregisteredAt: '2026-07-15');
|
||||
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
|
||||
. "09.09.2026;Sommerlager - Beitrag Max Meier;120,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertCount(1, $response->rows);
|
||||
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
|
||||
|
||||
// Die Prüfansicht muss die Abmeldung zeigen, sonst bucht jemand blind.
|
||||
$this->assertTrue($response->participants[0]['isSignedOff']);
|
||||
$this->assertSame('15.07.2026', $response->participants[0]['signedOffAt']);
|
||||
}
|
||||
|
||||
public function test_booking_works_for_a_signed_off_participant(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, unregisteredAt: '2026-07-15');
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 120.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Sommerlager - Beitrag Max Meier',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(1, $response->booked);
|
||||
|
||||
$participant->refresh();
|
||||
$this->assertSame(120.0, $participant->amount_paid->getAmount());
|
||||
// Damit ist die Grundlage für die Erstattung da: Konto bekannt, Betrag erfasst.
|
||||
$this->assertTrue($participant->refund_data);
|
||||
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
|
||||
}
|
||||
|
||||
public function test_parse_hides_payments_older_than_the_watermark(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', lastPaymentDate: '2026-09-09');
|
||||
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
|
||||
// Älter als die zuletzt erfasste Zahlung -- bereits gebucht.
|
||||
. "05.09.2026;Sommerlager - Beitrag Max Meier;40,00\r\n"
|
||||
// Gleicher Tag: eine zweite Rate am selben Tag soll durchkommen.
|
||||
. "09.09.2026;Sommerlager - Beitrag Max Meier;30,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertCount(1, $response->rows);
|
||||
$this->assertSame(1, $response->skippedOlderThanWatermark);
|
||||
$this->assertSame(30.0, $response->rows[0]['amount']);
|
||||
}
|
||||
|
||||
public function test_parse_reports_a_wrong_file(): void
|
||||
{
|
||||
$response = $this->parse($this->upload("Spalte A;Spalte B\r\n1;2\r\n"));
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('Buchungstag', $response->message);
|
||||
}
|
||||
|
||||
public function test_parse_rejects_a_non_csv_upload(): void
|
||||
{
|
||||
$response = $this->parse($this->upload('irgendwas', 'auszug.pdf'));
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('CSV', $response->message);
|
||||
}
|
||||
|
||||
public function test_booking_adds_to_the_paid_amount_and_records_the_account(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0);
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
'purpose' => 'Sommerlager - Beitrag Max Meier',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertSame(1, $response->booked);
|
||||
|
||||
$participant->refresh();
|
||||
// Aufaddiert, nicht überschrieben: 40 + 80.
|
||||
$this->assertSame(120.0, $participant->amount_paid->getAmount());
|
||||
$this->assertTrue($participant->refund_data);
|
||||
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
|
||||
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
|
||||
$this->assertSame('2026-09-09', $participant->last_payment_date->format('Y-m-d'));
|
||||
|
||||
Mail::assertSent(ParticipantPaymentPaidMail::class);
|
||||
}
|
||||
|
||||
public function test_booking_a_partial_payment_asks_for_the_rest(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', amount: 120.0);
|
||||
|
||||
new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 50.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Anzahlung',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
Mail::assertSent(ParticipantPaymentMissingPaymentMail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* In der Prüfansicht lässt sich eine Zeile von Hand einer anderen Person zuordnen -- deren
|
||||
* Wasserzeichen hat der erste Durchlauf nie gesehen. Deshalb wird serverseitig noch einmal geprüft.
|
||||
*/
|
||||
public function test_booking_skips_a_payment_older_than_the_watermark(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amountPaid: 40.0, lastPaymentDate: '2026-09-09');
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-01',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Alte Zahlung',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(0, $response->booked);
|
||||
$this->assertSame(1, $response->skipped);
|
||||
|
||||
$participant->refresh();
|
||||
$this->assertSame(40.0, $participant->amount_paid->getAmount());
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
/** Der Identifier kommt aus dem Browser -- eine fremde Anmeldung darf nicht gebucht werden. */
|
||||
public function test_booking_rejects_a_participant_from_another_event(): void
|
||||
{
|
||||
$otherEvent = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Herbstfahrt', 'identifier' => 'evt-2', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-10-01', 'end_date' => '2026-10-05',
|
||||
'early_bird_end' => '2026-09-01', 'registration_final_end' => '2026-09-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
|
||||
$foreign = $this->participant('p-1', 'Max', 'Meier');
|
||||
$foreign->event_id = $otherEvent->id;
|
||||
$foreign->save();
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Beitrag',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(0, $response->booked);
|
||||
$this->assertSame(1, $response->failed);
|
||||
|
||||
$foreign->refresh();
|
||||
$this->assertSame(0.0, $foreign->amount_paid->getAmount());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user