tenant = Tenant::create([ 'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1', 'email' => 't@example.com', 'email_finance' => 'finance@example.com', 'url' => parse_url(config('app.url'), PHP_URL_HOST), 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY', 'city' => 'Stadt', 'postcode' => '00000', 'invoice_prefix' => 'WM', 'is_active_local_group' => true, 'has_active_instance' => true, ]); app()->instance('tenant', $this->tenant); DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { UserRole::create(['slug' => $role, 'name' => $role]); } DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_LAYOUT, 'content' => '
{block:body}
', 'sort_order' => 10, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_BODY, 'content' => '{details_table}

{declaration_text}

', 'sort_order' => 20, ]); $this->actingAs($this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN)); Storage::fake('local'); Mail::fake(); } private function makeUser(string $firstname, string $lastname, string $role): User { return User::create([ 'username' => strtolower($lastname) . '-' . uniqid() . '@example.com', 'email' => strtolower($lastname) . '-' . uniqid() . '@example.com', 'firstname' => $firstname, 'lastname' => $lastname, 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => $role, 'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true, ]); } private function makeEvent(): Event { $fee = EventParticipationFee::create([ 'tenant' => $this->tenant->slug, 'type' => 'participant', 'name' => 'Sippe', 'description' => null, 'amount_standard' => 60.0, 'amount_reduced' => null, 'amount_solidarity' => null, ]); $costUnit = CostUnit::create([ 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, 'distance_allowance' => 0.25, 'mail_on_new' => false, 'allow_new' => true, 'archived' => false, ]); return Event::create([ 'cost_unit_id' => $costUnit->id, 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-' . uniqid(), 'location' => 'Ort', 'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-07-16', 'end_date' => '2026-07-20', 'early_bird_end' => '2026-06-20', 'registration_final_end' => '2026-07-01', 'early_bird_end_amount_increase' => 0, 'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'fixed', 'participation_fee_1' => $fee->id, 'pay_per_day' => true, 'pay_direct' => false, 'tax_liable' => false, 'vat_rate' => 0, 'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701', ]); } /** Standardfall: abgemeldet, voll gezahlt, Konto aus dem Zahlungseingang bekannt. */ private function makeParticipant(array $attributes = []): EventParticipant { $this->sequence++; return $this->makeEvent()->participants()->create(array_merge([ 'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(), 'invoice_sequence' => $this->sequence, 'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id, 'firstname' => 'Mika', 'lastname' => 'Muster', 'participation_type' => 'participant', 'fee_type' => 'standard', 'sibling_reduction' => false, 'local_group' => $this->tenant->slug, 'birthday' => '2000-01-01', 'address_1' => 'Beispielstraße 3', 'postcode' => '11111', 'city' => 'Beispielstadt', 'email_1' => 'mika@example.com', 'phone_1' => '0170 0000000', 'arrival_date' => '2026-07-16', 'departure_date' => '2026-07-20', 'arrival_eating' => 1, 'departure_eating' => 1, 'amount' => 300.0, 'amount_paid' => 300.0, 'unregistered_at' => '2026-06-12', 'payment_purpose' => 'Sommerlager', 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, // Was der Zahlungsimport hinterlassen hat. 'payment_options' => [ AccountTransferPaymentModule::OPTION_PAYER_IBAN => self::KNOWN_IBAN, AccountTransferPaymentModule::OPTION_PAYER_ACCOUNT_OWNER => self::KNOWN_OWNER, ], 'refund_data' => true, 'last_payment_date' => '2026-05-02', ], $attributes)); } private function release(EventParticipant $participant): ParticipantRefund { $response = new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant, amount: new Amount(300.0, 'Euro'), reason: RefundReason::SICKNESS, ))->execute(); $this->assertTrue($response->success, $response->message); return $response->refund; } /* |-------------------------------------------------------------------------- | Freigabe |-------------------------------------------------------------------------- */ public function test_the_known_account_is_stored_but_the_refund_stays_pending(): void { $refund = $this->release($this->makeParticipant()); // Offen, weil der Teili noch entscheiden soll: auszahlen oder spenden. $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status); $this->assertSame(self::KNOWN_IBAN, $refund->account_iban); $this->assertSame(self::KNOWN_OWNER, $refund->account_owner); } public function test_a_participant_without_payment_data_gets_no_account(): void { $refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false])); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status); $this->assertNull($refund->account_iban); $this->assertNull($refund->account_owner); } /** Barzahlung kennt kein Konto -- auch dann, wenn zufällig etwas in den Optionen steht. */ public function test_cash_payment_gets_no_account(): void { $refund = $this->release($this->makeParticipant([ 'payment_method' => PaymentMethod::PAYMENT_NOT_DEFINED, ])); $this->assertNull($refund->account_iban); } /* |-------------------------------------------------------------------------- | Was nach draußen geht |-------------------------------------------------------------------------- */ public function test_the_release_mail_shows_the_iban_only_masked(): void { $participant = $this->makeParticipant(); $this->release($participant); Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) { $rendered = $mail->render(); $this->assertStringContainsString('DE02 •••• •••• •••• ••20 51', $rendered); // Die vollständige IBAN liegt sonst in einem Postfach. $this->assertStringNotContainsString(self::KNOWN_IBAN, $rendered); $this->assertStringNotContainsString('DE02 1203 0000 0000 2020 51', $rendered); // Nach der Bankverbindung wird nicht mehr gefragt, nach der Spende schon. $this->assertStringNotContainsString('brauchen wir noch deine Bankverbindung', $rendered); $this->assertStringContainsString('spenden', $rendered); return true; }); } public function test_the_public_page_never_exposes_the_full_iban(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant); $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page ->where('state', 'open') ->where('knownAccount.owner', self::KNOWN_OWNER) ->where('knownAccount.ibanMasked', 'DE02 •••• •••• •••• ••20 51')); // Und zwar nirgends in der Antwort -- auch nicht in einem Feld, das niemand liest. $this->assertStringNotContainsString( self::KNOWN_IBAN, $this->get('/rueckerstattung/' . $refund->token)->getContent(), ); } public function test_the_public_page_asks_for_the_account_when_nothing_is_known(): void { $refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false])); // Kein Konto zum Anzeigen -- das Formular bleibt, wie es war. $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'open')->where('knownAccount', null)); } /* |-------------------------------------------------------------------------- | Bestätigung |-------------------------------------------------------------------------- */ public function test_the_participant_only_confirms_and_the_account_stands(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant); // Ohne Kontofelder -- genau das schickt die Seite bei bekanntem Konto. $response = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: '', accountIban: '', declarationAccepted: true, accountDeclarationAccepted: true, ))->execute(); $this->assertTrue($response->success, $response->message); $refund->refresh(); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status); $this->assertSame(self::KNOWN_IBAN, $refund->account_iban); $this->assertSame(self::KNOWN_OWNER, $refund->account_owner); } /** * Der eigentliche Gewinn: Wer den Token hat, kann die Auszahlung nicht auf ein fremdes Konto * umbiegen. Das Konto steht seit der Freigabe fest. */ public function test_a_foreign_iban_in_the_request_is_ignored(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant); $response = $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'donation' => false, 'accountOwner' => 'Fremde Person', 'accountIban' => 'DE89370400440532013000', 'declarationAccepted' => true, 'accountDeclarationAccepted' => true, ]); $response->assertOk(); $response->assertJsonPath('status', 'success'); $refund->refresh(); $this->assertSame(self::KNOWN_IBAN, $refund->account_iban); $this->assertSame(self::KNOWN_OWNER, $refund->account_owner); } /** Die Erklärungen bleiben Pflicht -- der Beleg zitiert sie. */ public function test_the_declarations_are_still_required(): void { $refund = $this->release($this->makeParticipant()); $response = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, declarationAccepted: false, accountDeclarationAccepted: false, ))->execute(); $this->assertFalse($response->success); $this->assertArrayHasKey('declaration', $response->errorTypes); $this->assertArrayHasKey('accountDeclaration', $response->errorTypes); // Und keine Meckerei über Felder, die gar nicht mehr gezeigt werden. $this->assertArrayNotHasKey('accountIban', $response->errorTypes); $this->assertArrayNotHasKey('accountOwner', $response->errorTypes); } /** * Wer spendet, bekommt nichts überwiesen -- die Kontofelder müssen leer werden, sonst zöge der * SEPA-Export eine Auszahlung, die niemand wollte. */ public function test_a_donation_clears_the_known_account(): void { $refund = $this->release($this->makeParticipant()); $response = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, donation: true, declarationAccepted: true, ))->execute(); $this->assertTrue($response->success, $response->message); $refund->refresh(); $this->assertNull($refund->account_iban); $this->assertNull($refund->account_owner); $this->assertTrue($refund->isDonation()); } /** Ohne bekanntes Konto bleibt die Eingabe Pflicht wie eh und je. */ public function test_without_a_known_account_the_fields_are_still_required(): void { $refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false])); $response = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: '', accountIban: '', declarationAccepted: true, accountDeclarationAccepted: true, ))->execute(); $this->assertFalse($response->success); $this->assertArrayHasKey('accountOwner', $response->errorTypes); $this->assertArrayHasKey('accountIban', $response->errorTypes); } }