tenant = Tenant::create([ 'slug' => 'wm', 'name' => 'Wilde Möhre', 'invoice_sender_name' => 'Wilde Möhre e.V.', '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' => 'Lommatzsch', 'postcode' => '01623', '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']); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); $this->seedTemplate(); } /** Die Vorlage setzt hier jeden Platzhalter ein, damit die Tests am gerenderten HTML prüfen können. */ private function seedTemplate(): void { DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_LAYOUT, 'content' => '
{block:header_recipient}{block:subject}{block:body}
', 'sort_order' => 10, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => 'header_recipient', 'content' => '

{recipient_name} / {recipient_address_1} / {recipient_postcode} {recipient_city}

', 'sort_order' => 35, ]); 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} / {service_period} / {unregistered_at}

' . '

{sender_name} / {recipient_name}

', 'sort_order' => 30, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_BODY, 'content' => '{details_table}

{refund_amount} / {refund_reason} / {refund_reason_text}

' . '

{account_owner} / {account_iban}

' . '

{paid_amount} / {invoice_number}

' . '

{declaration_text}

', 'sort_order' => 40, ]); } 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([ '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, array $attributes = []): EventParticipant { return $event->participants()->create(array_merge([ 'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(), 'invoice_sequence' => 5, '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)); } /** Ein bestätigter Vorgang -- der Beleg entsteht erst dann. */ private function makeRefund(array $attributes = [], array $eventAttributes = []): ParticipantRefund { $event = $this->makeEvent($eventAttributes); $participant = $this->makeParticipant($event); return ParticipantRefund::create(array_merge([ 'tenant' => $this->tenant->slug, 'event_id' => $event->id, 'event_participant_id' => $participant->id, 'token' => str_repeat('a', 32), 'status' => ParticipantRefund::STATUS_ACCEPTED, 'amount' => 300.0, 'reason' => RefundReason::SICKNESS, 'account_owner' => 'Mika Muster', 'account_iban' => 'DE02120300000000202051', 'released_at' => '2026-06-14 10:00:00', 'accepted_at' => '2026-06-18 09:30:00', ], $attributes)); } private function document(ParticipantRefund $refund) { return new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); } /** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */ private function html(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); } /* |-------------------------------------------------------------------------- | Nummer und PDF |-------------------------------------------------------------------------- */ public function test_document_number_extends_the_invoice_number(): void { $response = $this->document($this->makeRefund()); $this->assertTrue($response->success); $this->assertSame('WM-V-20260701-0005-R', $response->documentNumber); $this->assertSame('Rueckerstattung-WM-V-20260701-0005-R.pdf', $response->filename); } public function test_a_pdf_is_produced(): void { $response = $this->document($this->makeRefund()); $this->assertStringStartsWith('%PDF', $response->pdfContent); } public function test_no_document_without_an_invoice_key(): void { $response = $this->document($this->makeRefund(eventAttributes: ['invoice_key' => null])); $this->assertFalse($response->success); $this->assertStringContainsString('Belegnummer', $response->message); } public function test_no_document_before_the_participant_confirmed(): void { $response = $this->document($this->makeRefund([ 'status' => ParticipantRefund::STATUS_PENDING, 'account_owner' => null, 'account_iban' => null, 'accepted_at' => null, ])); $this->assertFalse($response->success); $this->assertStringContainsString('bestätigt', $response->message); } /* |-------------------------------------------------------------------------- | Inhalt |-------------------------------------------------------------------------- */ public function test_document_shows_amount_reason_and_bank_details(): void { $html = $this->html($this->makeRefund()); $this->assertStringContainsString('300,00', $html); $this->assertStringContainsString('Krankheitsbedingte Absage', $html); $this->assertStringContainsString('Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', $html); $this->assertStringContainsString('Mika Muster', $html); // Im Beleg steht die IBAN in Vierergruppen. $this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html); } public function test_document_shows_event_period_and_cancellation_date(): void { $html = $this->html($this->makeRefund()); $this->assertStringContainsString('Sommerlager', $html); $this->assertStringContainsString('16.07.2026 – 20.07.2026', $html); $this->assertStringContainsString('12.06.2026', $html); // Belegdatum ist der Tag der Bestätigung. $this->assertStringContainsString('18.06.2026', $html); } public function test_the_reason_is_a_row_in_the_table_not_a_block_below_it(): void { $refund = $this->makeRefund(); $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); $this->assertStringContainsString( 'Begründung' . 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', $details ); // Der frühere Fließtext-Block unter der Tabelle ist verschwunden. $this->assertStringNotContainsString('reason-note', $details); $this->assertStringEndsWith('', $details); } public function test_the_reason_row_is_dropped_when_there_is_no_text(): void { RefundReason::find(RefundReason::SICKNESS)->update(['document_text' => '']); $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); // Eine Beschriftung ohne Wert sieht auf einem Beleg nach Fehler aus. $this->assertStringNotContainsString('Begründung', $details); $this->assertStringContainsString('Krankheitsbedingte Absage', $details); } public function test_the_table_lists_the_rows_in_reading_order(): void { $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); preg_match_all('/([^<]+)<\/td>/', $details, $matches); // Erst wer erklärt, dann worauf sich die Erstattung bezieht, dann warum, dann wohin das Geld geht. $this->assertSame( [ 'Name', 'Gezahlter Teilnahmebeitrag', 'Rechnung', 'Erstattungsbetrag', 'Grund', 'Begründung', 'Kontoinhaber*in', 'IBAN', ], $matches[1] ); } public function test_the_document_carries_no_contact_details(): void { // Für die Abrechnung genügen Name und Anschrift; E-Mail und Telefon gehören nicht auf einen // Beleg, der durch die Buchhaltung und ins Archiv wandert. $html = $this->html($this->makeRefund()); $this->assertStringNotContainsString('mika@example.com', $html); $this->assertStringNotContainsString('0170 0000000', $html); } public function test_document_shows_the_paid_amount_next_to_the_refund(): void { // Teilerstattung: 220 von 300 gezahlten Euro. $html = $this->html($this->makeRefund(['amount' => 220.0])); $this->assertStringContainsString('300,00', $html); $this->assertStringContainsString('220,00', $html); // Die Nummer der Teilnahmerechnung, ohne das -R des Belegs. $this->assertStringContainsString('WM-V-20260701-0005', $html); } public function test_document_carries_the_declaration(): void { $html = $this->html($this->makeRefund()); $this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html); } public function test_the_declaration_comes_from_the_page_text(): void { DB::table('page_texts') ->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT) ->update(['content' => 'Eigener Wortlaut des Verbands.']); $this->assertStringContainsString('Eigener Wortlaut des Verbands.', $this->html($this->makeRefund())); } public function test_the_declaration_falls_back_when_the_page_text_is_missing(): void { // Ohne Rückfallwert stünde hier ein Fatal Error auf null -- der Beleg muss trotzdem entstehen. DB::table('page_texts')->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)->delete(); $this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund())); } public function test_free_text_reason_replaces_the_catalog_text(): void { $html = $this->html($this->makeRefund([ 'reason' => RefundReason::OTHER, 'reason_note' => 'Umzug in ein anderes Bundesland', ])); $this->assertStringContainsString('Sonstiger Grund', $html); $this->assertStringContainsString('Umzug in ein anderes Bundesland', $html); $this->assertStringNotContainsString('krankheitsbedingt', $html); } public function test_sender_is_read_live_from_the_tenant(): void { $refund = $this->makeRefund(); $this->tenant->update(['invoice_sender_name' => 'Neuer Name e.V.']); $this->assertStringContainsString('Neuer Name e.V.', $this->html($refund)); } public function test_single_day_event_shows_one_date(): void { $html = $this->html($this->makeRefund(eventAttributes: [ 'start_date' => '2026-07-16', 'end_date' => '2026-07-16', ])); $this->assertStringContainsString('16.07.2026', $html); $this->assertStringNotContainsString('–', $html); } /* |-------------------------------------------------------------------------- | Katalog und Werte müssen dieselben Namen kennen |-------------------------------------------------------------------------- */ public function test_token_catalog_matches_the_generated_values(): void { $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005-R'); $catalog = ParticipantRefundTokens::names(); sort($catalog); $generated = array_keys($tokens); sort($generated); $this->assertSame($catalog, $generated); } }