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]); 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]); } $this->seedTemplate(); // Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang. $this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN); $this->actingAs($this->management); Storage::fake('local'); Mail::fake(); } private function seedTemplate(): void { 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}

' . '{if:capture_note}

{capture_note}

{/if:capture_note}', 'sort_order' => 20, ]); } 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(array $attributes = []): 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(array_merge([ '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', ], $attributes)); } private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant { $event ??= $this->makeEvent(); $this->sequence++; return $event->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, ], $attributes)); } /** * Freigabe mit bereits bekannter Bankverbindung. * * 220 von 300 gezahlten Euro: Es bleibt etwas beim Verband, deshalb gehört ein Einbehaltungsgrund * dazu. */ private function releaseWithBankDetails( ?EventParticipant $participant = null, string $owner = 'Mika Muster', string $iban = 'DE02120300000000202051', float $amount = 220.0, ?string $retentionReason = RetentionReason::CANCELLATION_FEE, ) { return new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant ?? $this->makeParticipant(), amount: new Amount($amount, 'Euro'), reason: RefundReason::SICKNESS, accountOwner: $owner, accountIban: $iban, retentionReason: $retentionReason, ))->execute(); } /* |-------------------------------------------------------------------------- | Der Vorgang ist sofort abgeschlossen |-------------------------------------------------------------------------- */ public function test_the_refund_is_submitted_right_away(): void { $participant = $this->makeParticipant(); $response = $this->releaseWithBankDetails($participant); $this->assertTrue($response->success); $this->assertStringContainsString('eingereicht', $response->message); $refund = ParticipantRefund::first(); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status); $this->assertSame('Mika Muster', $refund->account_owner); $this->assertSame('DE02120300000000202051', $refund->account_iban); $this->assertNotNull($refund->accepted_at); } public function test_the_capturing_person_is_recorded(): void { $this->releaseWithBankDetails(); $refund = ParticipantRefund::first(); $this->assertSame($this->management->id, $refund->captured_by); $this->assertTrue($refund->wasCapturedByManagement()); } public function test_the_invoice_exists_and_the_paid_amount_is_settled(): void { $participant = $this->makeParticipant(); $this->releaseWithBankDetails($participant); $invoice = Invoice::first(); $this->assertNotNull($invoice); $this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001); $this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id); // 300 gezahlt, 220 erstattet -- die restlichen 80 bleiben beim Verband und werden dort geführt. $this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001); } public function test_a_full_refund_leaves_nothing_behind(): void { $participant = $this->makeParticipant(); $this->releaseWithBankDetails($participant, amount: 300.0, retentionReason: null); $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->assertEqualsWithDelta(0.0, ParticipantRefund::first()->retained_amount->getAmount(), 0.001); $this->assertNull(ParticipantRefund::first()->retention_reason); } public function test_only_the_receipt_mail_goes_out(): void { $this->releaseWithBankDetails(); // Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos. Mail::assertNotSent(RefundReleasedMail::class); Mail::assertSent(RefundAcceptedMail::class); } /* |-------------------------------------------------------------------------- | Der Beleg weist aus, wer die Angaben aufgenommen hat |-------------------------------------------------------------------------- */ public function test_the_receipt_names_who_captured_the_details(): void { $this->releaseWithBankDetails(); $html = $this->renderReceipt(ParticipantRefund::first()); // Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst. $this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html); $this->assertStringContainsString('Ich versichere', $html); } public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void { // Gegenprobe: der gewöhnliche Weg bleibt unverändert. $refund = new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $this->makeParticipant(), amount: new Amount(220.0, 'Euro'), reason: RefundReason::SICKNESS, retentionReason: RetentionReason::CANCELLATION_FEE, ))->execute()->refund; $refund->update([ 'status' => ParticipantRefund::STATUS_ACCEPTED, 'account_owner' => 'Mika Muster', 'account_iban' => 'DE02120300000000202051', 'accepted_at' => now(), ]); $this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh())); } private function renderReceipt(ParticipantRefund $refund): string { $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); $number = new \ReflectionMethod($command, 'documentNumber')->invoke($command); $tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number); return new \App\Providers\DocumentTemplateRenderProvider( DocumentTemplate::TYPE_PARTICIPANT_REFUND )->render($tokens); } /* |-------------------------------------------------------------------------- | Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen |-------------------------------------------------------------------------- */ public function test_an_invalid_iban_stops_everything(): void { // Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher. $response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015'); $this->assertFalse($response->success); $this->assertStringContainsString('IBAN', $response->message); $this->assertSame(0, ParticipantRefund::count()); $this->assertSame(0, Invoice::count()); } public function test_half_filled_bank_details_are_refused(): void { $response = new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $this->makeParticipant(), amount: new Amount(220.0, 'Euro'), reason: RefundReason::SICKNESS, accountOwner: 'Mika Muster', ))->execute(); $this->assertFalse($response->success); $this->assertSame(0, ParticipantRefund::count()); } public function test_without_a_cost_unit_nothing_is_created(): void { $participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null])); $response = $this->releaseWithBankDetails($participant); $this->assertFalse($response->success); $this->assertStringContainsString('Kostenstelle', $response->message); $this->assertSame(0, ParticipantRefund::count()); $this->assertSame(0, Invoice::count()); // Der gezahlte Beitrag bleibt unangetastet. $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); } /* |-------------------------------------------------------------------------- | Über HTTP |-------------------------------------------------------------------------- */ public function test_release_over_http_submits_directly(): void { $participant = $this->makeParticipant(); $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '220,00', 'reason' => RefundReason::SICKNESS, // 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung. 'retentionReason' => RetentionReason::CANCELLATION_FEE, 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', ]) ->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED); $this->assertNotNull(ParticipantRefund::first()->invoice_id); } public function test_release_over_http_without_bank_details_keeps_the_old_way(): void { $participant = $this->makeParticipant(); $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '220,00', 'reason' => RefundReason::SICKNESS, // 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung. 'retentionReason' => RetentionReason::CANCELLATION_FEE, 'accountOwner' => '', 'accountIban' => '', ]) ->assertOk() ->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING); Mail::assertSent(RefundReleasedMail::class); $this->assertSame(0, Invoice::count()); $this->assertNull(ParticipantRefund::first()->captured_by); } }