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