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']); 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']); $this->seedTemplate(); } /** Minimale Vorlage: alles, was die Tests brauchen, in einem Block. */ private function seedTemplate(): void { DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE, 'block' => DocumentTemplate::BLOCK_LAYOUT, 'content' => '
{block:subject}{block:body}
', 'sort_order' => 10, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE, 'block' => DocumentTemplate::BLOCK_STYLE, 'content' => 'body { font-size: 10pt; }', 'sort_order' => 20, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE, 'block' => 'subject', 'content' => '

Rechnung {invoice_number}

{invoice_date} / {service_period}

', 'sort_order' => 30, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE, 'block' => DocumentTemplate::BLOCK_BODY, 'content' => '{positions_table}{summary_table}
{closing_statement}
', 'sort_order' => 40, 'editable' => false, ]); } 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, 'payment_purpose' => 'Sommerlager', 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, ], $attributes)); } /** @return array> */ private function lines(EventParticipant $participant): array { $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); return new ReflectionMethod($command, 'buildLines')->invoke($command); } /* |-------------------------------------------------------------------------- | Positionen |-------------------------------------------------------------------------- */ public function test_fee_line_is_derived_from_amount_and_split_per_day(): void { $lines = $this->lines($this->makeParticipant($this->makeEvent())); $this->assertCount(1, $lines); $this->assertSame('Teilnahme Sommerlager in Gruppe Sippe (Standardbeitrag)', $lines[0]['description']); $this->assertSame(5.0, $lines[0]['quantity']); $this->assertEqualsWithDelta(60.0, $lines[0]['unitPrice'], 0.001); $this->assertEqualsWithDelta(300.0, $lines[0]['amount'], 0.001); } public function test_addons_are_own_lines_and_reduce_the_fee_share(): void { $event = $this->makeEvent(); $participant = $this->makeParticipant($event, ['amount' => 345.0]); $participant->selectedAddons()->create([ 'tenant' => $this->tenant->slug, 'event_id' => $event->id, 'addon_key' => 'shirt', 'title' => 'T-Shirt', 'price' => 45.0, 'flat' => true, 'days' => 1, 'amount' => 45.0, ]); $lines = $this->lines($participant->fresh()); $this->assertCount(2, $lines); // Beitragsanteil = amount - Zusätze, nicht neu berechnet. $this->assertEqualsWithDelta(300.0, $lines[0]['amount'], 0.001); $this->assertSame('T-Shirt', $lines[1]['description']); $this->assertEqualsWithDelta(45.0, $lines[1]['amount'], 0.001); $this->assertEqualsWithDelta(345.0, array_sum(array_column($lines, 'amount')), 0.001); } public function test_sibling_reduction_shows_full_day_rate_and_a_discount_line(): void { $participant = $this->makeParticipant($this->makeEvent(), [ 'sibling_reduction' => true, 'amount' => 150.0, ]); $lines = $this->lines($participant); $this->assertCount(2, $lines); // Der ausgewiesene Tagessatz ist der echte (60 €), nicht der halbierte. $this->assertEqualsWithDelta(60.0, $lines[0]['unitPrice'], 0.001); $this->assertEqualsWithDelta(300.0, $lines[0]['amount'], 0.001); $this->assertSame('Geschwisterermäßigung 50 %', $lines[1]['description']); $this->assertEqualsWithDelta(-150.0, $lines[1]['amount'], 0.001); // Die Summe bleibt der tatsächlich zu zahlende Betrag. $this->assertEqualsWithDelta(150.0, array_sum(array_column($lines, 'amount')), 0.001); } public function test_legacy_registration_without_fee_type_still_adds_up(): void { $participant = $this->makeParticipant($this->makeEvent(), [ 'fee_type' => null, 'sibling_reduction' => false, ]); $lines = $this->lines($participant); $this->assertSame('Teilnahme Sommerlager in Gruppe Sippe', $lines[0]['description']); $this->assertEqualsWithDelta(300.0, array_sum(array_column($lines, 'amount')), 0.001); } public function test_uneven_day_rate_falls_back_to_a_single_line(): void { // 637,50 auf 4 Tage ergibt 159,375 -- gerundet würde 4 x 159,38 = 637,52 ergeben. Eine Rechnung, // die sich nicht nachrechnen lässt, wäre unbrauchbar, also entfällt die Aufteilung. $participant = $this->makeParticipant($this->makeEvent(), [ 'amount' => 637.5, 'departure_date' => '2026-07-19', ]); $lines = $this->lines($participant); $this->assertSame(1.0, $lines[0]['quantity']); $this->assertEqualsWithDelta(637.5, $lines[0]['unitPrice'], 0.001); $this->assertEqualsWithDelta(637.5, $lines[0]['amount'], 0.001); } /* |-------------------------------------------------------------------------- | Umsatzsteuer |-------------------------------------------------------------------------- */ /** @return array{net: float, vat: float} */ private function splitVat(EventParticipant $participant, float $gross): array { $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); return new ReflectionMethod($command, 'splitVat')->invoke($command, $gross); } public function test_vat_is_extracted_identically_in_both_pricing_modes(): void { $inclusive = $this->makeParticipant($this->makeEvent([ 'tax_liable' => true, 'vat_rate' => 19, 'vat_pricing_mode' => 'inclusive', ])); $addOn = $this->makeParticipant($this->makeEvent([ 'tax_liable' => true, 'vat_rate' => 19, 'vat_pricing_mode' => 'add_on', 'invoice_key' => 'WM-V-20260702', ])); // Gespeichert ist immer der finale Brutto-Betrag -- für die Rechnung spielt der Preismodus // deshalb keine Rolle mehr. $this->assertEquals($this->splitVat($inclusive, 119.0), $this->splitVat($addOn, 119.0)); $this->assertEqualsWithDelta(19.0, $this->splitVat($inclusive, 119.0)['vat'], 0.001); $this->assertEqualsWithDelta(100.0, $this->splitVat($inclusive, 119.0)['net'], 0.001); } public function test_without_tax_liability_no_vat_is_shown_but_the_legal_note_is(): void { // Die Befreiungsgründe kommen bereits aus der Migration, inklusive ihres Pflichthinweises. $reason = TaxExemptionReason::find(TaxExemptionReason::USTG_4_25A); $participant = $this->makeParticipant($this->makeEvent([ 'tax_liable' => false, 'tax_exemption_reason' => $reason->slug, ])); $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); $summary = new ReflectionMethod($command, 'renderSummary')->invoke($command, 300.0); $this->assertStringNotContainsString('USt.', $summary); $this->assertStringContainsString($reason->invoice_text, $summary); } /* |-------------------------------------------------------------------------- | Zahlungs-Schlusssatz |-------------------------------------------------------------------------- */ private function closingStatement(EventParticipant $participant): string { $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); return new ReflectionMethod($command, 'closingStatement')->invoke($command); } public function test_open_amount_is_requested_by_transfer(): void { $statement = $this->closingStatement($this->makeParticipant($this->makeEvent())); $this->assertStringContainsString('Bitte überweise', $statement); } public function test_cash_payment_asks_for_the_amount_on_site(): void { $participant = $this->makeParticipant($this->makeEvent(), [ 'payment_method' => PaymentMethod::PAYMENT_NOT_DEFINED, ]); $this->assertStringContainsString( 'in bar am ersten Tag der Veranstaltung bereit', $this->closingStatement($participant) ); } public function test_fully_paid_participant_is_not_asked_to_pay_again(): void { $participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 300.0]); $statement = $this->closingStatement($participant); $this->assertStringContainsString('bereits vollständig beglichen', $statement); $this->assertStringNotContainsString('Bitte überweise', $statement); } /* |-------------------------------------------------------------------------- | Rechnungsnummer und Dokument |-------------------------------------------------------------------------- */ public function test_invoice_number_is_deterministic(): void { $participant = $this->makeParticipant($this->makeEvent(), ['invoice_sequence' => 5]); $first = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant))->execute(); $second = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant->fresh()))->execute(); $this->assertTrue($first->success); $this->assertSame('WM-V-20260701-0005', $first->invoiceNumber); $this->assertSame($first->invoiceNumber, $second->invoiceNumber); $this->assertSame('Rechnung-WM-V-20260701-0005.pdf', $first->filename); $this->assertStringStartsWith('%PDF', $first->pdfContent); } public function test_the_endpoint_delivers_the_pdf_as_a_download(): void { $participant = $this->makeParticipant($this->makeEvent(), ['invoice_sequence' => 5]); $this->actingAs($this->makeLeader()); $response = $this->get('/api/v1/participant-invoice/' . $participant->identifier); $response->assertOk(); $response->assertHeader('Content-Type', 'application/pdf'); $response->assertHeader('Content-Disposition', 'attachment; filename="Rechnung-WM-V-20260701-0005.pdf"'); } public function test_the_endpoint_is_closed_to_guests(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->get('/api/v1/participant-invoice/' . $participant->identifier)->assertRedirect('/login'); } private function makeLeader(): User { foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { UserRole::firstOrCreate(['slug' => $role], ['name' => $role]); } return User::create([ 'username' => 'leitung@example.com', 'email' => 'leitung@example.com', 'firstname' => 'Leitung', 'lastname' => 'Person', 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => UserRole::USER_ROLE_USER, 'user_role_local_group' => UserRole::USER_ROLE_GROUP_LEADER, 'active' => true, ]); } public function test_service_period_collapses_for_single_day_events(): void { $singleDay = $this->makeParticipant($this->makeEvent([ 'start_date' => '2027-01-01', 'end_date' => '2027-01-01', ])); $multiDay = $this->makeParticipant($this->makeEvent([ 'start_date' => '2027-01-06', 'end_date' => '2027-01-08', 'invoice_key' => 'WM-V-20270102', ])); $period = static fn(EventParticipant $p): string => new ReflectionMethod( $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($p)), 'servicePeriod' )->invoke($command); $this->assertSame('01.01.2027', $period($singleDay)); $this->assertSame('06.01.2027 – 08.01.2027', $period($multiDay)); } /* |-------------------------------------------------------------------------- | Vorlage |-------------------------------------------------------------------------- */ public function test_empty_blocks_disappear(): void { DocumentTemplate::where('block', 'subject')->update(['content' => '']); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE) ->render(['positions_table' => 'POS', 'summary_table' => '', 'closing_statement' => '']); $this->assertStringNotContainsString('Rechnung', $html); $this->assertStringNotContainsString('{block:', $html); $this->assertStringContainsString('POS', $html); } public function test_template_content_is_never_evaluated_as_blade(): void { // Der Inhalt kommt aus einem Admin-Formular. Würde er durch Blade laufen, wäre das Formular ein // Weg zur Codeausführung auf dem Server. DocumentTemplate::where('block', 'subject')->update([ 'content' => '

{{ 7*7 }} @php echo "ausgefuehrt"; @endphp

', ]); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]); // Beides bleibt wörtlich stehen -- nichts davon wird ausgewertet. $this->assertStringContainsString('{{ 7*7 }}', $html); $this->assertStringContainsString('@php echo "ausgefuehrt"; @endphp', $html); $this->assertStringNotContainsString('49', $html); $this->assertStringNotContainsString('>ausgefuehrt<', $html); } public function test_a_bare_data_uri_is_stripped_from_the_css(): void { // dompdf lagert Data-URIs im CSS nur aus, wenn ihnen ( " oder ' vorausgeht. Ein bar stehendes // bleibt liegen und treibt seinen Parser in quadratische Laufzeit -- unter FPM stirbt der // Worker am Zeitlimit und die Anfrage endet in einem 502. DocumentAsset::create(['name' => 'logo', 'mime' => 'image/png', 'data' => 'RUlORFJVQ0s']); DocumentTemplate::where('block', DocumentTemplate::BLOCK_STYLE)->update([ 'content' => '.a { color: red; }{asset:logo}{asset:logo}', ]); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]); $this->assertStringContainsString('.a { color: red; }', $html); $this->assertStringNotContainsString('data:image/png', $html); // Entscheidend ist der Rumpf: das Semikolon gehört zum Data-URI. Eine Begrenzung, die dort // aufhört, entfernt nur den Präfix und lässt das Base64 stehen -- der Parser bremst weiter. $this->assertStringNotContainsString('RUlORFJVQ0s', $html); $this->assertStringNotContainsString('base64', $html); } public function test_a_data_uri_inside_url_is_kept_in_the_css(): void { DocumentAsset::create(['name' => 'logo', 'mime' => 'image/png', 'data' => 'QUJD']); DocumentTemplate::where('block', DocumentTemplate::BLOCK_STYLE)->update([ 'content' => '.a { background: url("{asset:logo}"); }', ]); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]); // Korrekt eingebettet ist es für dompdf kein Problem und bleibt erhalten. $this->assertStringContainsString('url("data:image/png;base64,QUJD")', $html); } public function test_unknown_asset_resolves_to_nothing(): void { DocumentAsset::create([ 'name' => 'logo', 'mime' => 'image/png', 'data' => 'QUJD', ]); DocumentTemplate::where('block', 'subject')->update([ 'content' => '', ]); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]); $this->assertStringContainsString('src="data:image/png;base64,QUJD"', $html); $this->assertStringContainsString('src=""', $html); $this->assertStringNotContainsString('{asset:', $html); } /* |-------------------------------------------------------------------------- | Rechnungssteller |-------------------------------------------------------------------------- */ private function senderName(EventParticipant $participant): string { $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); return new ReflectionMethod($command, 'buildTokens') ->invoke($command, 'WM-V-20260701-0005', [], 300.0)['sender_name']; } public function test_without_an_own_entry_the_tenant_name_is_the_sender(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->assertSame('Wilde Möhre', $this->senderName($participant)); } public function test_an_own_invoice_sender_name_wins(): void { $this->tenant->update(['invoice_sender_name' => 'BdP Landesverband Sachsen e.V.']); $participant = $this->makeParticipant($this->makeEvent()); $this->assertSame('BdP Landesverband Sachsen e.V.', $this->senderName($participant->fresh())); } public function test_a_blank_entry_falls_back_to_the_tenant_name(): void { // Aus dem Formular kommt ein Leerstring, nicht null. $this->tenant->update(['invoice_sender_name' => ' ']); $participant = $this->makeParticipant($this->makeEvent()); $this->assertSame('Wilde Möhre', $this->senderName($participant->fresh())); } public function test_correcting_the_sender_reaches_existing_events(): void { // Der Fall, der vorher nicht funktioniert hat: der Absender wurde bei der Event-Anlage // eingefroren, eine spätere Korrektur wirkte deshalb auf bestehende Veranstaltungen nie. $participant = $this->makeParticipant($this->makeEvent()); $this->tenant->update([ 'invoice_sender_name' => 'BdP Landesverband Sachsen e.V.', 'address_1' => 'Bernhard-Göring-Str. 152', ]); $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant->fresh())); $tokens = new ReflectionMethod($command, 'buildTokens') ->invoke($command, 'WM-V-20260701-0005', [], 300.0); $this->assertSame('BdP Landesverband Sachsen e.V.', $tokens['sender_name']); $this->assertSame('Bernhard-Göring-Str. 152', $tokens['sender_address_1']); } public function test_the_tax_basis_stays_frozen_on_the_event(): void { // Gegenprobe zur Abgrenzung: die Steuergrundlage erklärt, wie der Preis zustande kam, und // folgt dem Mandanten ausdrücklich NICHT. $event = $this->makeEvent(['tax_liable' => true, 'vat_rate' => 19]); $this->tenant->update(['tax_liable' => false, 'vat_rate' => 0]); $this->assertTrue($event->fresh()->tax_liable); $this->assertSame(19, $event->fresh()->vat_rate); } public function test_token_catalogue_matches_what_the_command_actually_provides(): void { // Der Katalog speist die Platzhalter-Liste und die Vorschau in der Vorlagen-Verwaltung. Liefe er // gegenüber dem Command auseinander, würde die Verwaltung Platzhalter anbieten, die auf der echten // Rechnung leer bleiben -- oder umgekehrt welche verschweigen. $participant = $this->makeParticipant($this->makeEvent()); $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant)); $actual = array_keys( new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005', [], 300.0) ); sort($actual); $documented = ParticipantInvoiceTokens::names(); sort($documented); $this->assertSame($documented, $actual); } public function test_conditional_sections_drop_when_the_value_is_missing(): void { DocumentTemplate::where('block', 'subject')->update([ 'content' => '{if:sender_phone}Telefon: {sender_phone}{/if:sender_phone}' . '{if:sender_city}Ort: {sender_city}{/if:sender_city}', ]); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE) ->render(['sender_phone' => '', 'sender_city' => 'Stadt']); $this->assertStringNotContainsString('Telefon:', $html); $this->assertStringContainsString('Ort: Stadt', $html); } }