tenant = Tenant::create([ 'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1', 'email' => 't@example.com', 'email_finance' => 'finance@example.com', // Muss dem Host der Testanfragen entsprechen, sonst weist IdentifyTenant sie mit 404 ab. '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']); // Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen. // `invoice_types.participation_refund` bringt die Migration bereits mit. 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]); } $this->seedTemplate(); Mail::fake(); } /** Minimale Vorlage: alles, was der Beleg braucht, in wenigen Blöcken. */ private function seedTemplate(): void { DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_LAYOUT, 'content' => '
{block:subject}{block:body}
', 'sort_order' => 10, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_STYLE, 'content' => 'body { font-size: 10pt; }', 'sort_order' => 20, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => 'subject', 'content' => '

Rückerstattung {document_number}

{document_date} / {event_name}

', 'sort_order' => 30, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_BODY, 'content' => '
{recipient_name}
{details_table}', 'sort_order' => 40, ]); } /** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */ private function makeCostUnit(array $attributes = []): CostUnit { return CostUnit::create(array_merge([ '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, ], $attributes)); } 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, ]); return Event::create(array_merge([ 'cost_unit_id' => $this->makeCostUnit()->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)); } /** Standardfall dieser Tests: abgemeldet und vollständig bezahlt. */ private function makeParticipant(Event $event, array $attributes = []): EventParticipant { $this->sequence++; return $event->participants()->create(array_merge([ 'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(), 'invoice_sequence' => $this->sequence, '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)); } /** * Der Vorgabewert 300,00 € entspricht dem gezahlten Beitrag -- es bleibt also nichts einbehalten und * es braucht keinen Einbehaltungsgrund. Bei kleineren Beträgen muss einer mitgegeben werden. */ private function release( EventParticipant $participant, float $amount = 300.0, string $reason = RefundReason::SICKNESS, ?string $note = null, ?string $retentionReason = null, ) { return new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant, amount: new Amount($amount, 'Euro'), reason: $reason, reasonNote: $note, retentionReason: $retentionReason, ))->execute(); } private function accept( ?ParticipantRefund $refund, string $owner = 'Mika Muster', string $iban = 'DE02120300000000202051', bool $declarationAccepted = true, bool $accountDeclarationAccepted = true, ) { return new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: $owner, accountIban: $iban, declarationAccepted: $declarationAccepted, accountDeclarationAccepted: $accountDeclarationAccepted, ))->execute(); } /** Der zweite Weg: Der Teili verzichtet auf die Auszahlung und spendet -- ohne Bankverbindung. */ private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true) { return new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: '', accountIban: '', declarationAccepted: $declarationAccepted, donation: true, ))->execute(); } /* |-------------------------------------------------------------------------- | Freigabe |-------------------------------------------------------------------------- */ public function test_release_creates_a_pending_refund_with_a_token(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant); $this->assertTrue($response->success); $this->assertSame(ParticipantRefund::STATUS_PENDING, $response->refund->status); $this->assertSame(32, strlen($response->refund->token)); $this->assertEqualsWithDelta(300.0, $response->refund->amount->getAmount(), 0.001); $this->assertNotNull($response->refund->released_at); } public function test_release_notifies_participant_and_contact_person(): void { $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); $this->release($participant); Mail::assertSent(RefundReleasedMail::class, 2); Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com')); Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com')); } public function test_release_sends_only_one_mail_without_contact_person(): void { $this->release($this->makeParticipant($this->makeEvent())); Mail::assertSent(RefundReleasedMail::class, 1); } public function test_release_is_rejected_for_a_registered_participant(): void { $participant = $this->makeParticipant($this->makeEvent(), ['unregistered_at' => null]); $response = $this->release($participant); $this->assertFalse($response->success); $this->assertStringContainsString('abgemeldete', $response->message); $this->assertSame(0, ParticipantRefund::count()); } public function test_release_is_rejected_above_the_paid_amount(): void { $participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 100.0]); $response = $this->release($participant, 150.0); $this->assertFalse($response->success); $this->assertStringContainsString('nicht übersteigen', $response->message); } public function test_release_accepts_a_partial_amount(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant, 220.0, retentionReason: RetentionReason::CANCELLATION_FEE); $this->assertTrue($response->success); $this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001); // Der Rest wird am Vorgang festgeschrieben, nicht später gerechnet. $this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001); } public function test_release_is_rejected_without_an_amount(): void { $response = $this->release($this->makeParticipant($this->makeEvent()), 0.0); $this->assertFalse($response->success); $this->assertStringContainsString('größer als 0', $response->message); } public function test_release_is_rejected_for_an_unknown_reason(): void { $response = $this->release($this->makeParticipant($this->makeEvent()), 300.0, 'erfunden'); $this->assertFalse($response->success); $this->assertStringContainsString('Erstattungsgrund', $response->message); } public function test_free_text_reason_requires_a_note(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant, 300.0, RefundReason::OTHER, ' '); $this->assertFalse($response->success); $this->assertStringContainsString('Erläuterung', $response->message); } public function test_note_is_only_stored_for_reasons_that_require_it(): void { $event = $this->makeEvent(); $withNote = $this->release($this->makeParticipant($event), 300.0, RefundReason::OTHER, 'Umzug'); $this->assertSame('Umzug', $withNote->refund->reason_note); $ignored = $this->release($this->makeParticipant($event), 300.0, RefundReason::SICKNESS, 'wird verworfen'); $this->assertNull($ignored->refund->reason_note); } public function test_a_second_release_is_rejected_while_one_is_open(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->release($participant); $response = $this->release($participant); $this->assertFalse($response->success); $this->assertStringContainsString('läuft bereits', $response->message); $this->assertSame(1, ParticipantRefund::count()); } public function test_a_new_release_is_possible_after_a_cancellation(): void { $participant = $this->makeParticipant($this->makeEvent()); $first = $this->release($participant); new CancelRefundCommand(new CancelRefundRequest($first->refund))->execute(); $this->assertTrue($this->release($participant)->success); $this->assertSame(2, ParticipantRefund::count()); } /* |-------------------------------------------------------------------------- | Bestätigung durch den Teili |-------------------------------------------------------------------------- */ public function test_accept_stores_the_normalized_bank_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, 'Mika Muster', 'de02 1203 0000 0000 2020 51'); $this->assertTrue($response->success); $refund->refresh(); $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_accept_sends_the_confirmation_with_the_document_attached(): void { $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); $refund = $this->release($participant)->refund; $this->accept($refund); Mail::assertSent(RefundAcceptedMail::class, 2); $expected = sprintf( 'Rueckerstattung-WM-V-20260701-%s-R.pdf', str_pad((string) $participant->invoice_sequence, 4, '0', STR_PAD_LEFT) ); Mail::assertSent(RefundAcceptedMail::class, function (RefundAcceptedMail $mail) use ($expected): bool { $attachments = $mail->attachments(); return count($attachments) === 1 && $attachments[0]->as === $expected; }); } public function test_accept_rejects_an_invalid_iban(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Gültige Struktur und Länge, aber falsche Prüfziffer -- ein klassischer Zahlendreher. $response = $this->accept($refund, 'Mika Muster', 'DE02120300000000202015'); $this->assertFalse($response->success); $this->assertArrayHasKey('accountIban', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Die Erklärung |-------------------------------------------------------------------------- */ public function test_accept_is_rejected_without_the_declaration(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, declarationAccepted: false); $this->assertFalse($response->success); $this->assertArrayHasKey('declaration', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertNull($refund->fresh()->account_iban); } public function test_the_declaration_cannot_be_skipped_over_http(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Ohne die Prüfung im Command ließe sich die Erklärung mit einem direkten Aufruf übergehen -- // und der Beleg schriebe dem Teili eine Zusicherung zu, die er nie abgegeben hat. $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', ]) ->assertOk() ->assertJsonPath('status', 'error') ->assertJsonStructure(['error_types' => ['declaration']]); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } public function test_accept_is_rejected_without_the_account_declaration(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Erstattet wird nur auf das Konto, von dem der Beitrag kam. Fehlt die Bestätigung, ließe sich // über eine Erstattung Geld auf ein fremdes Konto umleiten. $response = $this->accept($refund, accountDeclarationAccepted: false); $this->assertFalse($response->success); $this->assertArrayHasKey('accountDeclaration', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertNull($refund->fresh()->account_iban); } public function test_the_account_declaration_cannot_be_skipped_over_http(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', 'declarationAccepted' => true, ]) ->assertOk() ->assertJsonPath('status', 'error') ->assertJsonStructure(['error_types' => ['accountDeclaration']]); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Spenden statt auszahlen |-------------------------------------------------------------------------- */ public function test_a_donation_is_accepted_without_bank_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->acceptAsDonation($refund); $this->assertTrue($response->success); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status); $this->assertTrue($refund->fresh()->isDonation()); } public function test_a_donation_still_needs_the_declaration(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Der Verzicht ist die Erklärung, die anschließend auf dem Beleg steht -- ohne sie nichts. $response = $this->acceptAsDonation($refund, declarationAccepted: false); $this->assertFalse($response->success); $this->assertArrayHasKey('declaration', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } public function test_a_donation_ignores_bank_details_sent_along(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Wer im Formular erst ein Konto eintippt und dann doch spendet, soll es nicht hinterlassen. $response = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: 'Mika Muster', accountIban: 'DE02120300000000202051', declarationAccepted: true, donation: true, ))->execute(); $this->assertTrue($response->success); $this->assertNull($refund->fresh()->account_owner); $this->assertNull($refund->fresh()->account_iban); } public function test_a_donation_over_http_needs_no_iban(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'donation' => true, 'declarationAccepted' => true, ])->assertOk()->assertJsonPath('status', 'success'); $this->assertTrue($refund->fresh()->isDonation()); } public function test_the_page_shows_a_finished_donation_as_donated(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->acceptAsDonation($refund); $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'accepted')->where('donation', true)); } public function test_the_public_page_serves_the_declaration_text_name(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'open')); // Den Wortlaut holt die Seite über dieselbe Quelle, aus der ihn auch der Beleg nimmt. $this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DECLARATION_TEXT) ->assertOk() ->assertSee('Ich versichere', false); } public function test_the_public_page_serves_the_new_declaration_texts(): void { // Beide Erklärungen stehen auf der Seite, die ohne Login erreichbar ist -- käme eine davon // nicht durch, stünde dort eine leere Checkbox, die sich trotzdem ankreuzen ließe. $this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT) ->assertOk() ->assertSee('dass das angegebene Konto dasselbe ist', false); $this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DONATION_DECLARATION_TEXT) ->assertOk() ->assertSee('verzichte auf die Auszahlung', false); } public function test_accept_requires_an_account_owner(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, ' '); $this->assertFalse($response->success); $this->assertArrayHasKey('accountOwner', $response->errorTypes); } public function test_accept_on_an_unknown_token_reports_no_open_refund(): void { $response = $this->accept(null); $this->assertFalse($response->success); $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); } public function test_accept_on_a_cancelled_refund_reports_no_open_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $response = $this->accept($refund->fresh()); $this->assertFalse($response->success); $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); } public function test_accept_twice_does_not_change_the_stored_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund, 'Mika Muster', 'DE02120300000000202051'); $response = $this->accept($refund->fresh(), 'Wer Anders', 'DE02500105170137075030'); $this->assertFalse($response->success); $this->assertSame('Mika Muster', $refund->fresh()->account_owner); } /* |-------------------------------------------------------------------------- | Abbruch |-------------------------------------------------------------------------- */ public function test_cancel_marks_the_refund_and_hides_it_from_the_participant(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $this->assertTrue($response->success); $this->assertSame(ParticipantRefund::STATUS_CANCELLED, $refund->fresh()->status); $this->assertNotNull($refund->fresh()->cancelled_at); // Für die Anmeldung sieht es danach aus wie vor der Freigabe. $repository = new ParticipantRefundRepository(); $this->assertNull($repository->openFor($participant)); $this->assertNull($repository->currentFor($participant)); } public function test_cancel_sends_no_mail(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; Mail::fake(); new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); Mail::assertNothingSent(); } public function test_cancel_is_rejected_after_acceptance(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $response = new CancelRefundCommand(new CancelRefundRequest($refund->fresh()))->execute(); $this->assertFalse($response->success); $this->assertStringContainsString('bereits bestätigt', $response->message); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Der gezahlte Beitrag bleibt unangetastet |-------------------------------------------------------------------------- */ public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void { $participant = $this->makeParticipant($this->makeEvent()); // Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag // unverändert beim Verband. $refund = $this->release($participant)->refund; $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->accept($refund); $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); // Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht. $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); } public function test_a_cancelled_release_leaves_amount_paid_alone(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); } /* |-------------------------------------------------------------------------- | Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite |-------------------------------------------------------------------------- */ private function makeAdmin(): User { return User::create([ 'username' => 'al-' . uniqid() . '@example.com', 'email' => 'al-' . uniqid() . '@example.com', 'firstname' => 'Aktions', 'lastname' => 'Leitung', 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => UserRole::USER_ROLE_ADMIN, 'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true, ]); } /** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */ private function makeParticipantUser(): User { return 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, ]); } public function test_release_over_http_creates_the_refund(): void { $this->actingAs($this->makeAdmin()); $participant = $this->makeParticipant($this->makeEvent()); $response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '220,50', 'reason' => RefundReason::SICKNESS, // Weniger als gezahlt -- der Rest bleibt beim Verband und braucht eine Begründung. 'retentionReason' => RetentionReason::CANCELLATION_FEE, ]); $response->assertOk()->assertJsonPath('status', 'success'); $response->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING); $refund = ParticipantRefund::first(); $this->assertEqualsWithDelta(220.5, $refund->amount->getAmount(), 0.001); // Die Antwort trägt den Vorgang für die Liste -- aber nicht den Token-Link als Bankdaten. $response->assertJsonPath('refund.amountValue', 220.5); } public function test_release_requires_a_login(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->post('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '300', 'reason' => RefundReason::SICKNESS, ])->assertRedirect('/login'); $this->assertSame(0, ParticipantRefund::count()); } public function test_the_public_page_shows_the_open_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->get('/rueckerstattung/' . $refund->token); $response->assertOk(); $response->assertInertia(fn ($page) => $page // shouldExist=false: die Pages liegen unter app/Domains/**/Views, nicht dort, wo der // Finder von Inertia sucht. ->component('ParticipantRefund/Views/RefundPage', false) ->where('state', 'open') ->where('amount', '300,00 Euro') ->where('reason', 'Krankheitsbedingte Absage')); } public function test_the_public_page_never_exposes_bank_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $response = $this->get('/rueckerstattung/' . $refund->fresh()->token); $response->assertOk(); $response->assertInertia(fn ($page) => $page->where('state', 'accepted')); $response->assertDontSee('DE02120300000000202051'); } public function test_the_public_page_hides_a_cancelled_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'unavailable')); } public function test_an_unknown_token_shows_the_same_page_as_a_cancelled_one(): void { $this->get('/rueckerstattung/' . str_repeat('x', 32)) ->assertOk() ->assertInertia(fn ($page) => $page ->where('state', 'unavailable') ->where('message', AcceptRefundCommand::NO_OPEN_REFUND)); } public function test_accept_over_http_works_without_a_login(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', 'declarationAccepted' => true, 'accountDeclarationAccepted' => true, ])->assertOk()->assertJsonPath('status', 'success'); $this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban); } public function test_field_errors_come_back_with_status_200(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Der HttpClient des Frontends verwirft Antworten mit Fehlerstatus -- Feldfehler müssen daher // mit 200 zurückkommen, sonst sieht der Teili nie, was er falsch gemacht hat. $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02120300000000202015', 'declarationAccepted' => true, ]) ->assertOk() ->assertJsonPath('status', 'error') ->assertJsonStructure(['error_types' => ['accountIban']]); } public function test_the_document_is_not_public(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document') ->assertRedirect('/login'); } public function test_the_document_can_be_downloaded_by_the_event_management(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $this->actingAs($this->makeAdmin()); $response = $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document'); $response->assertOk(); $response->assertHeader('Content-Type', 'application/pdf'); $this->assertStringStartsWith('%PDF', $response->getContent()); } public function test_cancel_requires_a_login(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->post('/api/v1/participant-refund/' . $refund->token . '/cancel') ->assertRedirect('/login'); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Die Mails müssen sich auch wirklich rendern lassen |-------------------------------------------------------------------------- */ public function test_the_release_mail_renders_with_the_link(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release( $participant, 220.0, RefundReason::OTHER, 'Umzug', RetentionReason::CANCELLATION_FEE )->refund; $html = new RefundReleasedMail($participant, $refund)->render(); $this->assertStringContainsString('220,00 Euro', $html); $this->assertStringContainsString('Sonstiger Grund', $html); $this->assertStringContainsString('Umzug', $html); $this->assertStringContainsString('/rueckerstattung/' . $refund->token, $html); // Der Baustein aus emails.subparts.disclaimer braucht eventTitle und eventEmail. $this->assertStringContainsString('Sommerlager', $html); $this->assertStringContainsString('e@example.com', $html); } public function test_the_release_mail_omits_the_note_row_when_there_is_none(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $html = new RefundReleasedMail($participant, $refund)->render(); $this->assertStringNotContainsString('Anmerkung:', $html); } public function test_the_acceptance_mail_renders_with_the_bank_details(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $this->accept($refund); $html = new RefundAcceptedMail($participant, $refund->fresh(), 'PDF-Inhalt', 'beleg.pdf')->render(); $this->assertStringContainsString('300,00 Euro', $html); $this->assertStringContainsString('Mika Muster', $html); // Im Anschreiben steht die IBAN in Vierergruppen. $this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html); $this->assertStringContainsString('Anhang', $html); } public function test_the_acceptance_mail_points_at_the_invoice(): void { $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]); $refund = $this->release($participant)->refund; $this->accept($refund); $html = new RefundAcceptedMail($participant, $refund->fresh())->render(); // Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits. $this->assertStringContainsString('musst nichts weiter tun', $html); $this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html); $this->assertStringContainsString('/invoice/my-invoices/new', $html); } public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void { // Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung. $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]); $refund = $this->release($participant)->refund; $this->accept($refund); $html = new RefundAcceptedMail($participant, $refund->fresh())->render(); $this->assertStringNotContainsString('/invoice/my-invoices/new', $html); } public function test_an_empty_second_address_gets_no_mail(): void { // Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL. $this->release($this->makeParticipant($this->makeEvent(), ['email_2' => ''])); Mail::assertSent(RefundReleasedMail::class, 1); } public function test_the_acceptance_mail_renders_without_a_document(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $this->accept($refund); $mail = new RefundAcceptedMail($participant, $refund->fresh()); $this->assertSame([], $mail->attachments()); $this->assertStringNotContainsString('Anhang', $mail->render()); } /* |-------------------------------------------------------------------------- | Gründe als Auswahl fürs Frontend |-------------------------------------------------------------------------- */ public function test_reason_options_are_ordered_and_flag_the_free_text(): void { $options = RefundReason::options(); $this->assertSame( ['sickness', 'event_cancelled', 'other'], array_column($options, 'value') ); $this->assertSame('Krankheitsbedingte Absage', $options[0]['label']); $this->assertFalse($options[0]['requiresNote']); $this->assertTrue($options[2]['requiresNote']); } public function test_reason_options_are_served_over_http(): void { $this->getJson('/api/v1/core/retrieve-refund-reasons') ->assertOk() ->assertJsonCount(3) ->assertJsonPath('0.value', RefundReason::SICKNESS) ->assertJsonPath('2.requiresNote', true); } }