344 lines
15 KiB
PHP
344 lines
15 KiB
PHP
<?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());
|
|
}
|
|
}
|