diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php index 0361b7d..e02187b 100644 --- a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php @@ -9,12 +9,12 @@ use App\Models\Tenant; class UpdateTenantTaxRequest { public function __construct( - public Tenant $tenant, - public bool $taxLiable, - public int $vatRate, + public Tenant $tenant, + public bool $taxLiable, + public int $vatRate, public ?TaxExemptionReason $exemptionReason, public ?string $exemptionNote, - public VatPricingMode $vatPricingMode, + public VatPricingMode $vatPricingMode, ) { } diff --git a/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php index 691c939..5d6af6e 100644 --- a/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php +++ b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php @@ -22,7 +22,7 @@ class ManagedTenantTaxUpdateController extends CommonController vatRate: (int)$request->input('vat_rate', 0), exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(), exemptionNote: $request->input('tax_exemption_note'), - vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, + vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, )); $response = $action->execute(); diff --git a/app/Domains/Admin/Controllers/TenantTaxUpdateController.php b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php index 1b2dea9..f8b17b8 100644 --- a/app/Domains/Admin/Controllers/TenantTaxUpdateController.php +++ b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php @@ -20,7 +20,7 @@ class TenantTaxUpdateController extends CommonController vatRate: (int)$request->input('vat_rate', 0), exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(), exemptionNote: $request->input('tax_exemption_note'), - vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, + vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, )); $response = $action->execute(); diff --git a/app/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php index a4af04e..10da598 100644 --- a/app/Enumerations/TaxExemptionReason.php +++ b/app/Enumerations/TaxExemptionReason.php @@ -44,7 +44,7 @@ class TaxExemptionReason extends CommonModel */ public function invoiceText(?string $customNote = null): string { - return $this->requires_note ? trim((string)$customNote) : $this->invoice_text; + return $this->requires_note ? trim((string) $customNote) : $this->invoice_text; } /** @@ -56,7 +56,7 @@ class TaxExemptionReason extends CommonModel public static function options(): array { return self::all() - ->map(static fn(self $reason): array => [ + ->map(static fn (self $reason): array => [ 'value' => $reason->slug, 'label' => $reason->name, 'text' => $reason->requires_note ? '' : $reason->invoice_text, diff --git a/app/Enumerations/VatPricingMode.php b/app/Enumerations/VatPricingMode.php new file mode 100644 index 0000000..ed5f9c0 --- /dev/null +++ b/app/Enumerations/VatPricingMode.php @@ -0,0 +1,38 @@ + 'Inklusive – Beiträge sind Endpreise (USt enthalten)', + self::AddOn => 'Aufschlagen – USt wird auf die Beiträge aufgeschlagen', + }; + } + + /** + * Optionen für das Frontend: [['value' => ..., 'label' => ...], ...]. + * + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $mode): array => [ + 'value' => $mode->value, + 'label' => $mode->label(), + ], + self::cases(), + ); + } +} diff --git a/database/migrations/2026_08_06_160000_add_vat_pricing_mode_to_tenants.php b/database/migrations/2026_08_06_160000_add_vat_pricing_mode_to_tenants.php new file mode 100644 index 0000000..c29dc62 --- /dev/null +++ b/database/migrations/2026_08_06_160000_add_vat_pricing_mode_to_tenants.php @@ -0,0 +1,21 @@ +string('vat_pricing_mode')->default('inclusive'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('vat_pricing_mode'); + }); + } +}; diff --git a/database/migrations/2026_08_06_160010_add_tax_snapshot_to_events.php b/database/migrations/2026_08_06_160010_add_tax_snapshot_to_events.php new file mode 100644 index 0000000..8f3e5d9 --- /dev/null +++ b/database/migrations/2026_08_06_160010_add_tax_snapshot_to_events.php @@ -0,0 +1,36 @@ +boolean('tax_liable')->default(false); + $table->unsignedTinyInteger('vat_rate')->default(0); + $table->string('vat_pricing_mode')->default('inclusive'); + $table->string('tax_exemption_reason')->nullable(); + $table->text('tax_exemption_note')->nullable(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn([ + 'tax_liable', + 'vat_rate', + 'vat_pricing_mode', + 'tax_exemption_reason', + 'tax_exemption_note', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_06_170000_create_tax_exemption_reasons.php b/database/migrations/2026_08_06_170000_create_tax_exemption_reasons.php new file mode 100644 index 0000000..114aeb9 --- /dev/null +++ b/database/migrations/2026_08_06_170000_create_tax_exemption_reasons.php @@ -0,0 +1,23 @@ +string('slug')->primary(); + $table->string('name'); + $table->text('invoice_text')->nullable(); + $table->boolean('requires_note')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tax_exemption_reasons'); + } +}; diff --git a/database/migrations/2026_08_06_170010_add_tax_exemption_reason_foreign_keys.php b/database/migrations/2026_08_06_170010_add_tax_exemption_reason_foreign_keys.php new file mode 100644 index 0000000..3360061 --- /dev/null +++ b/database/migrations/2026_08_06_170010_add_tax_exemption_reason_foreign_keys.php @@ -0,0 +1,48 @@ +insertOrIgnore([ + ['slug' => 'small_business', 'name' => 'Kleinunternehmer (§ 19 UStG)', 'invoice_text' => 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now], + ['slug' => 'education', 'name' => 'Bildungs-/Kursleistung (§ 4 Nr. 22a UStG)', 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now], + ['slug' => 'youth_welfare', 'name' => 'Kinder- und Jugendhilfe (§ 4 Nr. 25 UStG)', 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now], + ['slug' => 'custom', 'name' => 'Sonstiger Grund (Freitext)', 'invoice_text' => null, 'requires_note' => true, 'created_at' => $now, 'updated_at' => $now], + ]); + + Schema::table('tenants', function (Blueprint $table) { + $table->foreign('tax_exemption_reason') + ->references('slug')->on('tax_exemption_reasons') + ->restrictOnDelete()->cascadeOnUpdate(); + }); + + Schema::table('events', function (Blueprint $table) { + $table->foreign('tax_exemption_reason') + ->references('slug')->on('tax_exemption_reasons') + ->restrictOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropForeign(['tax_exemption_reason']); + }); + + Schema::table('events', function (Blueprint $table) { + $table->dropForeign(['tax_exemption_reason']); + }); + } +}; diff --git a/tests/Feature/EventVatPricingTest.php b/tests/Feature/EventVatPricingTest.php new file mode 100644 index 0000000..6d96586 --- /dev/null +++ b/tests/Feature/EventVatPricingTest.php @@ -0,0 +1,155 @@ +tenant = Tenant::create([ + 'slug' => 'tv', + 'name' => 'Test', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => 'test.local', + 'account_name' => 'Test 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); + + DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); + 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']); + } + + private function makeFee(float $amountStandard): EventParticipationFee + { + return EventParticipationFee::create([ + 'tenant' => $this->tenant->slug, + 'type' => 'participant', + 'name' => 'Standard', + 'description' => null, + 'amount_standard' => $amountStandard, + 'amount_reduced' => null, + 'amount_solidarity' => null, + ]); + } + + private function makeEvent(bool $taxLiable, int $vatRate, string $mode, int $feeId): Event + { + return Event::create([ + 'tenant' => $this->tenant->slug, + 'name' => 'Event', + 'identifier' => 'evt-' . uniqid(), + 'location' => 'Ort', + 'postal_code' => '00000', + 'email' => 'e@example.com', + 'start_date' => '2026-09-01', + 'end_date' => '2026-09-05', + 'early_bird_end' => '2026-08-20', + 'registration_final_end' => '2026-08-25', + 'early_bird_end_amount_increase' => 0, + 'account_owner' => 'Owner', + 'account_iban' => 'DE00', + 'participation_fee_type' => 'fixed', + 'participation_fee_1' => $feeId, + 'pay_per_day' => false, + 'pay_direct' => false, + 'tax_liable' => $taxLiable, + 'vat_rate' => $vatRate, + 'vat_pricing_mode' => $mode, + ]); + } + + private function calculate(Event $event): float + { + return new EventResource($event)->calculateAmount( + 'participant', + 'standard', + new DateTime('2026-09-01'), + new DateTime('2026-09-01'), + false, + )->getAmount(); + } + + public function test_add_on_mode_adds_vat_on_top(): void + { + $event = $this->makeEvent(taxLiable: true, vatRate: 19, mode: 'add_on', feeId: $this->makeFee(100.0)->id); + + $this->assertEqualsWithDelta(119.0, $this->calculate($event), 0.001); + } + + public function test_inclusive_mode_keeps_amount(): void + { + $event = $this->makeEvent(taxLiable: true, vatRate: 19, mode: 'inclusive', feeId: $this->makeFee(100.0)->id); + + $this->assertEqualsWithDelta(100.0, $this->calculate($event), 0.001); + } + + public function test_not_liable_ignores_add_on_mode(): void + { + $event = $this->makeEvent(taxLiable: false, vatRate: 19, mode: 'add_on', feeId: $this->makeFee(100.0)->id); + + $this->assertEqualsWithDelta(100.0, $this->calculate($event), 0.001); + } + + public function test_create_event_snapshots_tenant_tax_config(): void + { + $this->tenant->update([ + 'tax_liable' => true, + 'vat_rate' => 7, + 'vat_pricing_mode' => 'add_on', + 'tax_exemption_reason' => null, + 'tax_exemption_note' => null, + ]); + + $response = new CreateEventCommand(new CreateEventRequest( + name: 'Sommerlager', + location: 'Ort', + postalCode: '00000', + email: 'e@example.com', + begin: new DateTime('2026-09-01'), + end: new DateTime('2026-09-05'), + earlyBirdEnd: new DateTime('2026-08-20'), + registrationFinalEnd: new DateTime('2026-08-25'), + earlyBirdEndAmountIncrease: 0, + participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(), + accountOwner: 'Owner', + accountIban: 'DE00', + payPerDay: false, + ))->execute(); + + $this->assertTrue($response->success); + + $event = $response->event->fresh(); + $this->assertTrue($event->tax_liable); + $this->assertSame(7, $event->vat_rate); + $this->assertSame('add_on', $event->vat_pricing_mode); + } +}