Teilnahmerechnungen erstellen

This commit is contained in:
2026-09-03 00:08:46 +02:00
parent a61344395a
commit 9c4c28e566
79 changed files with 4303 additions and 75 deletions
+215
View File
@@ -0,0 +1,215 @@
<?php
namespace Tests\Feature;
use App\Enumerations\UserRole;
use App\Models\DocumentAsset;
use App\Models\DocumentTemplate;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Die Vorlagen gelten app-weit für alle Mandanten -- Zugriff deshalb nur für Hauptadministrator*innen.
*/
class DocumentTemplateAdminTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
// Bewusst kein LV-Tenant: dort leitet AuthCheckProvider die Rolle aus `user_role_main` ab, sodass
// sich eine Gruppenleitung gar nicht erst darstellen ließe.
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'email' => 'wm@example.com',
'email_finance' => 'wm-f@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Wilde Möhre e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
// `users.user_role_main` und `user_role_local_group` sind Fremdschlüssel auf `user_roles`.
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:footer}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => 'footer',
'content' => 'alt',
'sort_order' => 20,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{positions_table}',
'sort_order' => 30,
'editable' => false,
]);
}
private function makeUser(string $mainRole, string $localRole = UserRole::USER_ROLE_USER): User
{
return User::create([
'username' => $mainRole . '-' . uniqid() . '@example.com',
'email' => $mainRole . '-' . uniqid() . '@example.com',
'firstname' => 'Test',
'lastname' => 'Person',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => $mainRole,
'user_role_local_group' => $localRole,
'active' => true,
]);
}
public function test_group_leaders_cannot_reach_the_templates(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_USER, UserRole::USER_ROLE_GROUP_LEADER));
$this->get('/admin/document-templates')->assertRedirect('/admin');
$this->getJson('/api/v1/admin/document-templates')->assertRedirect('/admin');
}
public function test_main_administrators_can_read_the_templates(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
$response = $this->getJson('/api/v1/admin/document-templates');
$response->assertOk();
$response->assertJsonPath('blocks.1.block', 'footer');
$response->assertJsonPath('blocks.1.editable', true);
// Der generierte Block wird angezeigt, aber als nicht editierbar gekennzeichnet.
$response->assertJsonPath('blocks.2.editable', false);
}
public function test_saving_leaves_generated_blocks_untouched(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
$this->postJson('/api/v1/admin/document-templates', [
'blocks' => [
'footer' => 'neu',
DocumentTemplate::BLOCK_BODY => 'manipuliert',
'gibtesnicht' => 'egal',
],
])->assertOk()->assertJsonPath('status', 'success');
$blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE);
$this->assertSame('neu', $blocks['footer']->content);
$this->assertSame('{positions_table}', $blocks[DocumentTemplate::BLOCK_BODY]->content);
}
public function test_a_bare_asset_placeholder_in_the_css_is_refused(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => DocumentTemplate::BLOCK_STYLE,
'content' => '.a { color: red; }',
'sort_order' => 15,
]);
$response = $this->postJson('/api/v1/admin/document-templates', [
'blocks' => [
DocumentTemplate::BLOCK_STYLE => '.a { color: red; }{asset:logo}',
'footer' => 'neu',
],
]);
$response->assertOk()->assertJsonPath('status', 'error');
$this->assertStringContainsString('url("{asset:logo}")', $response->json('message'));
// Nichts gespeichert -- auch nicht der unbedenkliche Block.
$blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE);
$this->assertSame('.a { color: red; }', $blocks[DocumentTemplate::BLOCK_STYLE]->content);
$this->assertSame('alt', $blocks['footer']->content);
}
public function test_an_asset_inside_url_is_accepted_in_the_css(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
'block' => DocumentTemplate::BLOCK_STYLE,
'content' => '',
'sort_order' => 15,
]);
$this->postJson('/api/v1/admin/document-templates', [
'blocks' => [DocumentTemplate::BLOCK_STYLE => '.a { background: url("{asset:logo}"); }'],
])->assertOk()->assertJsonPath('status', 'success');
$this->assertStringContainsString(
'url("{asset:logo}")',
DocumentTemplate::where('block', DocumentTemplate::BLOCK_STYLE)->first()->content
);
}
public function test_preview_renders_unsaved_content(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
$response = $this->post('/api/v1/admin/document-templates/preview', [
'blocks' => ['footer' => 'noch nicht gespeichert'],
]);
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
// Der gespeicherte Stand bleibt unberührt.
$this->assertSame('alt', DocumentTemplate::where('block', 'footer')->first()->content);
}
public function test_assets_still_used_in_the_template_are_kept(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentAsset::create(['name' => 'logo', 'mime' => 'image/png', 'data' => 'QUJD']);
DocumentTemplate::where('block', 'footer')->update(['content' => '<img src="{asset:logo}" />']);
$this->deleteJson('/api/v1/admin/document-templates/assets/logo')
->assertOk()
->assertJsonPath('status', 'error');
$this->assertNotNull(DocumentAsset::where('name', 'logo')->first());
}
public function test_unused_assets_can_be_deleted(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentAsset::create(['name' => 'altbestand', 'mime' => 'image/png', 'data' => 'QUJD']);
$this->deleteJson('/api/v1/admin/document-templates/assets/altbestand')
->assertOk()
->assertJsonPath('status', 'success');
$this->assertNull(DocumentAsset::where('name', 'altbestand')->first());
}
}
+26 -8
View File
@@ -2,18 +2,36 @@
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
use RefreshDatabase;
$response->assertStatus(200);
/**
* Rauchtest über den Anfrage-Stack: die Anwendung ist mandantenfähig, ohne passenden Tenant zum Host
* beantwortet {@see \App\Middleware\IdentifyTenant} jede Anfrage mit 404. Mit passendem Tenant kommt
* die Anfrage durch und wird als Gast erwartungsgemäß zum Login geleitet.
*/
public function test_the_application_redirects_guests_to_the_login(): void
{
Tenant::create([
'slug' => 'tv',
'name' => 'Test',
'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' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
$this->get('/')->assertRedirect('/login');
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\CreateEvent\CreateEventCommand;
use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Die Rechnungsnummer wird nirgends gespeichert, sondern aus `events.invoice_key` und
* `event_participants.invoice_sequence` zusammengesetzt. Beide Bausteine müssen deshalb stabil sein.
*/
class InvoiceNumberingTest extends TestCase
{
use RefreshDatabase;
private Tenant $wildeMoehre;
protected function setUp(): void
{
parent::setUp();
$this->wildeMoehre = $this->makeTenant('wm', 'Wilde Möhre');
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
app()->instance('tenant', $this->wildeMoehre);
}
/**
* `create()` liefert nur die übergebenen Attribute zurück; die DB-Defaults (u.a. `tax_liable`) stehen
* erst nach dem Neuladen bereit -- so, wie der Tenant im Betrieb aus der IdentifyTenant-Middleware kommt.
*/
private function makeTenant(string $slug, string $name): Tenant
{
return Tenant::create([
'slug' => $slug,
'name' => $name,
'address_1' => 'Musterweg 1',
'email' => $slug . '@example.com',
'email_finance' => $slug . '-f@example.com',
'url' => $slug . '.test.local',
'account_name' => $name,
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => strtoupper($slug),
'is_active_local_group' => true,
'has_active_instance' => true,
])->fresh();
}
private function createEvent(string $begin, string $name = 'Lager'): Event
{
$response = new CreateEventCommand(new CreateEventRequest(
name: $name,
location: 'Ort',
postalCode: '00000',
email: 'e@example.com',
begin: new DateTime($begin),
end: new DateTime($begin),
earlyBirdEnd: new DateTime('2026-01-01'),
registrationFinalEnd: new DateTime('2026-01-02'),
earlyBirdEndAmountIncrease: 0,
participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(),
accountOwner: 'Owner',
accountIban: 'DE00',
payPerDay: false,
))->execute();
$this->assertTrue($response->success);
return $response->event->fresh();
}
/*
|--------------------------------------------------------------------------
| Event-Teil der Nummer
|--------------------------------------------------------------------------
*/
public function test_events_of_the_same_month_are_numbered_consecutively(): void
{
$this->assertSame('WM-V-20260701', $this->createEvent('2026-07-16')->invoice_key);
$this->assertSame('WM-V-20260702', $this->createEvent('2026-07-24')->invoice_key);
// Anderer Monat, eigene Zählung.
$this->assertSame('WM-V-20260801', $this->createEvent('2026-08-03')->invoice_key);
}
public function test_each_tenant_counts_for_itself(): void
{
$this->createEvent('2026-07-16');
app()->instance('tenant', $this->makeTenant('sn', 'Sachsen'));
$this->assertSame('SN-V-20260701', $this->createEvent('2026-07-18')->invoice_key);
}
public function test_invoice_key_survives_moving_the_event_to_another_month(): void
{
$event = $this->createEvent('2026-07-16');
$event->update(['start_date' => '2026-11-05', 'end_date' => '2026-11-07']);
// Eingefroren: eine bereits herausgegebene Rechnung gehört sonst plötzlich zu einer anderen Nummer.
$this->assertSame('WM-V-20260701', $event->fresh()->invoice_key);
}
public function test_new_tenants_default_to_their_uppercased_slug(): void
{
$tenant = Tenant::create([
'slug' => 'fen',
'name' => 'Fen',
'email' => 'fen@example.com',
'email_finance' => 'fen-f@example.com',
'url' => 'fen.test.local',
'account_name' => 'Fen',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
])->fresh();
// Ohne gesetztes Präfix greift der Migrations-Default: der großgeschriebene Slug.
$this->assertSame('FEN', $tenant->invoice_prefix);
app()->instance('tenant', $tenant);
$this->assertSame('FEN-V-20260701', $this->createEvent('2026-07-16')->invoice_key);
}
/*
|--------------------------------------------------------------------------
| Teilnehmer-Teil der Nummer
|--------------------------------------------------------------------------
*/
private function signUp(Event $event, string $firstname): void
{
$response = new SignUpCommand(new SignUpRequest(
$event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
$this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
strtolower($firstname) . '@example.com', '123', null, null, null, null, null, null, null,
'vegan', null, null, false, false, false, false, false,
new DateTime('2026-07-16'), new DateTime('2026-07-16'), 0, 0, null,
new Amount(50.0, 'Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
))->execute();
$this->assertTrue($response->success, $response->message ?? '');
}
public function test_sequences_are_handed_out_in_order(): void
{
$event = $this->createEvent('2026-07-16');
$this->signUp($event, 'Ada');
$this->signUp($event, 'Bo');
$this->signUp($event, 'Cem');
$this->assertSame([1, 2, 3], $event->participants()->orderBy('id')->pluck('invoice_sequence')->all());
}
public function test_signing_off_does_not_shift_later_sequences(): void
{
$event = $this->createEvent('2026-07-16');
$this->signUp($event, 'Ada');
$this->signUp($event, 'Bo');
// Abmeldung: der Datensatz bleibt bestehen, die Nummer damit vergeben.
$event->participants()->orderBy('id')->first()->update(['unregistered_at' => '2026-07-01']);
$this->signUp($event, 'Cem');
$this->assertSame(3, $event->participants()->orderBy('id')->get()->last()->invoice_sequence);
}
public function test_sequences_are_per_event(): void
{
$first = $this->createEvent('2026-07-16', 'Erstes');
$second = $this->createEvent('2026-07-24', 'Zweites');
$this->signUp($first, 'Ada');
$this->signUp($second, 'Bo');
$this->assertSame(1, $first->participants()->first()->invoice_sequence);
$this->assertSame(1, $second->participants()->first()->invoice_sequence);
}
}
+630
View File
@@ -0,0 +1,630 @@
<?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);
}
}
+6 -2
View File
@@ -80,11 +80,15 @@ class TenantTaxInformationTest extends TestCase
public function test_invalid_exemption_reason_fails(): void
{
// Neue Tenants tragen den DB-Default (Jugendhilfe); verglichen wird deshalb gegen den Stand
// vor der Aktion, nicht gegen null.
$before = $this->tenant->fresh()->tax_exemption_reason;
$response = $this->runAction(taxLiable: false, vatRate: 0, reason: 'not_a_reason');
$this->assertFalse($response->success);
// Unbekannter Slug wird nicht aufgelöst (null) → kein Speichern, Wert bleibt unverändert.
$this->assertNull($this->tenant->fresh()->tax_exemption_reason);
// Unbekannter Slug wird nicht aufgelöst → kein Speichern, Wert bleibt unverändert.
$this->assertSame($before, $this->tenant->fresh()->tax_exemption_reason);
}
public function test_custom_reason_requires_note(): void