Files
mareike/tests/Unit/BankStatementMatchTest.php
2026-09-09 16:55:08 +02:00

226 lines
8.6 KiB
PHP

<?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);
}
}