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(); Storage::fake('local'); Mail::fake(); } /** Die Vorlage setzt den Datenblock ein -- daran lassen sich die Belegzeilen prüfen. */ 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}

{retained_amount} / {retention_note}

', 'sort_order' => 20, ]); } 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', // Je Test können mehrere Veranstaltungen entstehen; der Schlüssel ist eindeutig. 'invoice_key' => 'WM-V-2026070' . ($this->sequence + 1), ]); } private function makeParticipant(array $attributes = []): EventParticipant { $this->sequence++; $user = User::create([ 'username' => 'teili-' . uniqid() . '@example.com', 'email' => 'teili-' . uniqid() . '@example.com', 'firstname' => 'Mika', 'lastname' => 'Muster', 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => UserRole::USER_ROLE_USER, 'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true, ]); return $this->makeEvent()->participants()->create(array_merge([ 'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(), 'invoice_sequence' => $this->sequence, 'user_id' => $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)); } private function release( EventParticipant $participant, float $amount, ?string $retentionReason = null, ?string $retentionNote = null, ) { return new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant, amount: new Amount($amount, 'Euro'), reason: RefundReason::SICKNESS, retentionReason: $retentionReason, retentionReasonNote: $retentionNote, ))->execute(); } private function accept(ParticipantRefund $refund): void { new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: 'Mika Muster', accountIban: 'DE02120300000000202051', declarationAccepted: true, accountDeclarationAccepted: true, ))->execute(); } /* |-------------------------------------------------------------------------- | Der Grund ist Pflicht, sobald etwas bleibt |-------------------------------------------------------------------------- */ public function test_a_partial_refund_without_a_reason_is_refused(): void { $response = $this->release($this->makeParticipant(), 220.0); $this->assertFalse($response->success); $this->assertStringContainsString('einbehalten', $response->message); $this->assertSame(0, ParticipantRefund::count()); } public function test_a_full_refund_needs_no_reason(): void { $response = $this->release($this->makeParticipant(), 300.0); $this->assertTrue($response->success); $this->assertNull($response->refund->retention_reason); $this->assertEqualsWithDelta(0.0, $response->refund->retained_amount->getAmount(), 0.001); } public function test_a_free_text_retention_reason_needs_its_note(): void { $response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, ' '); $this->assertFalse($response->success); $this->assertStringContainsString('Erläuterung', $response->message); $this->assertSame(0, ParticipantRefund::count()); } public function test_an_unknown_retention_reason_is_refused(): void { $response = $this->release($this->makeParticipant(), 220.0, 'erfunden'); $this->assertFalse($response->success); $this->assertSame(0, ParticipantRefund::count()); } public function test_a_reason_sent_with_a_full_refund_is_not_stored(): void { // Im Formular ist das Feld dann gar nicht sichtbar -- ein Wert ohne Bezug gehört nicht in die DB. $response = $this->release($this->makeParticipant(), 300.0, RetentionReason::CANCELLATION_FEE); $this->assertTrue($response->success); $this->assertNull($response->refund->retention_reason); } public function test_the_note_is_only_stored_for_reasons_that_require_it(): void { $withNote = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt'); $this->assertSame('Bereits gebuchte Bahnfahrt', $withNote->refund->retention_reason_note); $ignored = $this->release($this->makeParticipant(), 220.0, RetentionReason::MATERIAL, 'wird verworfen'); $this->assertNull($ignored->refund->retention_reason_note); } /* |-------------------------------------------------------------------------- | Was beim Verband bleibt |-------------------------------------------------------------------------- */ public function test_the_retained_amount_is_recorded_at_release(): void { $response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CANCELLATION_FEE); // Festgeschrieben, nicht gerechnet: Nach dem Einreichen führt `amount_paid` bereits den Rest. $this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001); $this->assertTrue($response->refund->hasRetention()); } public function test_amount_paid_keeps_the_retained_share(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund; $this->accept($refund); $this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001); } public function test_a_full_refund_leaves_nothing(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 300.0)->refund; $this->accept($refund); $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); } public function test_a_second_refund_is_capped_at_the_remainder(): void { $participant = $this->makeParticipant(); $this->accept($this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund); // Es liegen noch 80 € beim Verband -- mehr kann nicht zurückgehen. $tooMuch = $this->release($participant->fresh(), 100.0, RetentionReason::CANCELLATION_FEE); $this->assertFalse($tooMuch->success); $fits = $this->release($participant->fresh(), 80.0); $this->assertTrue($fits->success); $this->assertEqualsWithDelta(0.0, $fits->refund->retained_amount->getAmount(), 0.001); } /* |-------------------------------------------------------------------------- | Sichtbarkeit |-------------------------------------------------------------------------- */ public function test_the_receipt_names_the_retained_amount_and_reason(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund; $this->accept($refund); $html = $this->renderReceipt($refund->fresh()); $this->assertStringContainsString('Einbehalten', $html); $this->assertStringContainsString('80,00', $html); $this->assertStringContainsString('Stornogebühr laut Ausschreibung', $html); } public function test_the_receipt_stays_silent_on_a_full_refund(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 300.0)->refund; $this->accept($refund); $this->assertStringNotContainsString('Einbehalten', $this->renderReceipt($refund->fresh())); } public function test_the_mail_explains_the_retention(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt')->refund; $this->accept($refund); $html = new RefundAcceptedMail($participant, $refund->fresh())->render(); $this->assertStringContainsString('80,00 Euro', $html); $this->assertStringContainsString('Sonstiger Grund', $html); $this->assertStringContainsString('Bereits gebuchte Bahnfahrt', $html); } public function test_the_mail_stays_silent_on_a_full_refund(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 300.0)->refund; $this->accept($refund); $this->assertStringNotContainsString('verbleiben beim Verband', new RefundAcceptedMail($participant, $refund->fresh())->render()); } public function test_the_resource_carries_the_retention_for_the_lists(): void { $participant = $this->makeParticipant(); $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund; $this->accept($refund); $data = $refund->fresh()->toResource()->toArray(request()); $this->assertTrue($data['hasRetention']); $this->assertSame('80,00 Euro', $data['retainedAmount']); $this->assertSame('Stornogebühr laut Ausschreibung', $data['retentionReasonLabel']); } 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 DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens); } }