Teilnahmerechnungen erstellen
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user