Files
mareike/tests/Feature/ParticipantInvoiceTest.php
T

631 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Domains\ParticipantInvoice\Actions\CreateParticipantInvoice\CreateParticipantInvoiceCommand;
use App\Domains\ParticipantInvoice\Actions\CreateParticipantInvoice\CreateParticipantInvoiceRequest;
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
use App\Enumerations\EfzStatus;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\UserRole;
use App\Models\DocumentAsset;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\Providers\DocumentTemplateRenderProvider;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use ReflectionMethod;
use Tests\TestCase;
class ParticipantInvoiceTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->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' => '<div>{block:subject}{block:body}</div>',
'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' => '<h1>Rechnung {invoice_number}</h1><p>{invoice_date} / {service_period}</p>',
'sort_order' => 30,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{positions_table}{summary_table}<div>{closing_statement}</div>',
'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<int, array<string, mixed>> */
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' => '<p>{{ 7*7 }} @php echo "ausgefuehrt"; @endphp</p>',
]);
$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' => '<img src="{asset:logo}" /><img src="{asset:gibtesnicht}" />',
]);
$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);
}
}