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);
}
}