From b3bbeb5b0b6733262e8a695a305e80a8af46631b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnther?= Date: Thu, 6 Aug 2026 11:49:58 +0200 Subject: [PATCH] Added tax support --- .../UpdateTenantTax/UpdateTenantTaxAction.php | 66 +++++ .../UpdateTenantTaxRequest.php | 21 ++ .../UpdateTenantTaxResponse.php | 9 + .../ManagedTenantTaxGetController.php | 28 ++ .../ManagedTenantTaxUpdateController.php | 35 +++ .../Controllers/TenantTaxGetController.php | 26 ++ .../Controllers/TenantTaxUpdateController.php | 33 +++ app/Domains/Admin/Routes/api.php | 20 +- .../Admin/Views/Partials/TenantTaxInfo.vue | 240 ++++++++++++++++++ app/Domains/Admin/Views/TenantData.vue | 6 + app/Domains/Admin/Views/TenantEdit.vue | 6 + .../CreateEvent/CreateEventCommand.php | 7 + app/Enumerations/TaxExemptionReason.php | 66 +++++ app/Models/Event.php | 11 +- app/Models/Tenant.php | 17 +- app/Resources/EventResource.php | 9 +- ..._150000_add_tax_information_to_tenants.php | 29 +++ tests/Feature/TenantTaxInformationTest.php | 131 ++++++++++ 18 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxResponse.php create mode 100644 app/Domains/Admin/Controllers/ManagedTenantTaxGetController.php create mode 100644 app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php create mode 100644 app/Domains/Admin/Controllers/TenantTaxGetController.php create mode 100644 app/Domains/Admin/Controllers/TenantTaxUpdateController.php create mode 100644 app/Domains/Admin/Views/Partials/TenantTaxInfo.vue create mode 100644 app/Enumerations/TaxExemptionReason.php create mode 100644 database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php create mode 100644 tests/Feature/TenantTaxInformationTest.php diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php new file mode 100644 index 0000000..bb9fad7 --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php @@ -0,0 +1,66 @@ +request->taxLiable) { + $this->request->tenant->update([ + 'tax_liable' => true, + 'vat_rate' => $this->clampVatRate($this->request->vatRate), + 'vat_pricing_mode' => $this->request->vatPricingMode->value, + 'tax_exemption_reason' => null, + 'tax_exemption_note' => null, + ]); + + $response->success = true; + $response->message = 'Steuerinformationen wurden gespeichert.'; + + return $response; + } + + $reason = $this->request->exemptionReason; + + if ($reason === null) { + $response->message = 'Bitte einen gültigen Befreiungsgrund auswählen.'; + + return $response; + } + + $note = trim((string)$this->request->exemptionNote); + + if ($reason->requires_note && $note === '') { + $response->message = 'Bitte einen Text für den Befreiungsgrund angeben.'; + + return $response; + } + + $this->request->tenant->update([ + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => VatPricingMode::Inclusive->value, + 'tax_exemption_reason' => $reason->slug, + 'tax_exemption_note' => $reason->requires_note ? $note : null, + ]); + + $response->success = true; + $response->message = 'Steuerinformationen wurden gespeichert.'; + + return $response; + } + + private function clampVatRate(int $vatRate): int + { + return max(0, min(100, $vatRate)); + } +} diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php new file mode 100644 index 0000000..0361b7d --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php @@ -0,0 +1,21 @@ +adminTenants->findBySlug($slug); + + return response()->json([ + 'tax_liable' => $tenant->tax_liable, + 'vat_rate' => $tenant->vat_rate, + 'tax_exemption_reason' => $tenant->tax_exemption_reason, + 'tax_exemption_note' => $tenant->tax_exemption_note, + 'vat_pricing_mode' => $tenant->vat_pricing_mode, + 'exemption_reasons' => TaxExemptionReason::options(), + 'pricing_modes' => VatPricingMode::options(), + 'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax', + ]); + } +} diff --git a/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php new file mode 100644 index 0000000..691c939 --- /dev/null +++ b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php @@ -0,0 +1,35 @@ +adminTenants->findBySlug($slug); + + $action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $tenant, + taxLiable: $request->boolean('tax_liable'), + 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, + )); + + $response = $action->execute(); + + return response()->json([ + 'status' => $response->success ? 'success' : 'error', + 'message' => $response->message, + ]); + } +} diff --git a/app/Domains/Admin/Controllers/TenantTaxGetController.php b/app/Domains/Admin/Controllers/TenantTaxGetController.php new file mode 100644 index 0000000..d8cce22 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxGetController.php @@ -0,0 +1,26 @@ +json([ + 'tax_liable' => $this->tenant->tax_liable, + 'vat_rate' => $this->tenant->vat_rate, + 'tax_exemption_reason' => $this->tenant->tax_exemption_reason, + 'tax_exemption_note' => $this->tenant->tax_exemption_note, + 'vat_pricing_mode' => $this->tenant->vat_pricing_mode, + 'exemption_reasons' => TaxExemptionReason::options(), + 'pricing_modes' => VatPricingMode::options(), + 'saveEndpoint' => '/api/v1/admin/tenant/tax', + ]); + } +} diff --git a/app/Domains/Admin/Controllers/TenantTaxUpdateController.php b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php new file mode 100644 index 0000000..1b2dea9 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php @@ -0,0 +1,33 @@ +tenant, + taxLiable: $request->boolean('tax_liable'), + 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, + )); + + $response = $action->execute(); + + return response()->json([ + 'status' => $response->success ? 'success' : 'error', + 'message' => $response->message, + ]); + } +} diff --git a/app/Domains/Admin/Routes/api.php b/app/Domains/Admin/Routes/api.php index a7a7069..6bf9597 100644 --- a/app/Domains/Admin/Routes/api.php +++ b/app/Domains/Admin/Routes/api.php @@ -1,10 +1,5 @@ Route::post('/impress', TenantImpressUpdateController::class); Route::get('/gdpr', TenantGdprGetController::class); Route::post('/gdpr', TenantGdprUpdateController::class); + Route::get('/tax', TenantTaxGetController::class); + Route::post('/tax', TenantTaxUpdateController::class); }); Route::prefix('api/v1/admin/users')->group(function () { @@ -69,6 +75,8 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])-> Route::post('/impress', ManagedTenantImpressUpdateController::class); Route::get('/gdpr', ManagedTenantGdprGetController::class); Route::post('/gdpr', ManagedTenantGdprUpdateController::class); + Route::get('/tax', ManagedTenantTaxGetController::class); + Route::post('/tax', ManagedTenantTaxUpdateController::class); }); }); }); diff --git a/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue b/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue new file mode 100644 index 0000000..0ab4f77 --- /dev/null +++ b/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/app/Domains/Admin/Views/TenantData.vue b/app/Domains/Admin/Views/TenantData.vue index 145088f..24b81cb 100644 --- a/app/Domains/Admin/Views/TenantData.vue +++ b/app/Domains/Admin/Views/TenantData.vue @@ -7,6 +7,7 @@ import TenantPayment from "./Partials/TenantPayment.vue"; import TenantImpress from "./Partials/TenantImpress.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue"; import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue"; +import TenantTaxInfo from "./Partials/TenantTaxInfo.vue"; const props = defineProps({ tenant: Object, @@ -28,6 +29,11 @@ const tabs = [ component: TenantPaymentMethods, endpoint: '/api/v1/admin/tenant/payment-methods', }, + { + title: 'Steuerinformationen', + component: TenantTaxInfo, + endpoint: '/api/v1/admin/tenant/tax', + }, { title: 'Impressum', component: TenantImpress, diff --git a/app/Domains/Admin/Views/TenantEdit.vue b/app/Domains/Admin/Views/TenantEdit.vue index 36e4a2a..579b636 100644 --- a/app/Domains/Admin/Views/TenantEdit.vue +++ b/app/Domains/Admin/Views/TenantEdit.vue @@ -7,6 +7,7 @@ import TenantContact from "./Partials/TenantContact.vue"; import TenantPayment from "./Partials/TenantPayment.vue"; import TenantImpress from "./Partials/TenantImpress.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue"; +import TenantTaxInfo from "./Partials/TenantTaxInfo.vue"; const props = defineProps({ tenant: Object, @@ -30,6 +31,11 @@ const tabs = [ component: TenantPayment, endpoint: '/api/v1/admin/tenants/' + slug + '/payment', }, + { + title: 'Steuerinformationen', + component: TenantTaxInfo, + endpoint: '/api/v1/admin/tenants/' + slug + '/tax', + }, { title: 'Impressum', component: TenantImpress, diff --git a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php index 72302ab..7a1ccef 100644 --- a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php +++ b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php @@ -47,6 +47,13 @@ class CreateEventCommand { 'total_max_amount' => 0, 'support_per_person' => 0, 'support_flat' => 0, + // Umsatzsteuerliche Grundlage bei Anlage einfrieren (unveränderlich), damit Preisermittlung und + // spätere Rechnungen von späteren Tenant-Änderungen unberührt bleiben. + 'tax_liable' => app('tenant')->tax_liable, + 'vat_rate' => app('tenant')->vat_rate, + 'vat_pricing_mode' => app('tenant')->vat_pricing_mode, + 'tax_exemption_reason' => app('tenant')->tax_exemption_reason, + 'tax_exemption_note' => app('tenant')->tax_exemption_note, ]); if ($event !== null) { diff --git a/app/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php new file mode 100644 index 0000000..a4af04e --- /dev/null +++ b/app/Enumerations/TaxExemptionReason.php @@ -0,0 +1,66 @@ + 'boolean', + ]; + + /** + * Pflichthinweis für die Rechnung. Bei einem individuellen Grund (requires_note) wird der hinterlegte + * Freitext des Tenants verwendet. + */ + public function invoiceText(?string $customNote = null): string + { + return $this->requires_note ? trim((string)$customNote) : $this->invoice_text; + } + + /** + * Optionen für das Frontend inkl. Vorschau-Text. Bei einem Freitext-Grund ist `text` leer, da der Text + * erst aus dem Freitext des Tenants entsteht. + * + * @return array + */ + public static function options(): array + { + return self::all() + ->map(static fn(self $reason): array => [ + 'value' => $reason->slug, + 'label' => $reason->name, + 'text' => $reason->requires_note ? '' : $reason->invoice_text, + ]) + ->all(); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 20c26bd..ffce8ee 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -8,14 +8,11 @@ use App\Enumerations\EatingHabit; use App\Enumerations\ParticipationFeeType; use App\RelationModels\EventParticipationFee; use App\RelationModels\EventPaymentMethods; -use App\Resources\EventResource; -use App\Scopes\CommonModel; use App\Scopes\InstancedModel; use DateTime; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Http\Resources\Json\JsonResource; /** * @property string $tenant @@ -85,6 +82,11 @@ class Event extends InstancedModel 'support_flat', 'alcoholics_age', 'archived', + 'tax_liable', + 'vat_rate', + 'vat_pricing_mode', + 'tax_exemption_reason', + 'tax_exemption_note', ]; @@ -109,6 +111,9 @@ class Event extends InstancedModel 'total_max_amount' => AmountCast::class, 'sibling_reduction' => 'boolean', + + 'tax_liable' => 'boolean', + 'vat_rate' => 'integer', ]; public function tenant(): BelongsTo diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1efd913..3ef844d 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -21,6 +21,11 @@ use App\Scopes\CommonModel; * @property string $url_participation_rules * @property boolean $events_allowed * @property boolean $has_active_instance + * @property boolean $tax_liable + * @property int $vat_rate + * @property string|null $tax_exemption_reason + * @property string|null $tax_exemption_note + * @property string $vat_pricing_mode */ class Tenant extends CommonModel { @@ -46,6 +51,16 @@ class Tenant extends CommonModel 'impress_text', 'url_participation_rules', 'is_active_local_group', - 'has_active_instance' + 'has_active_instance', + 'tax_liable', + 'vat_rate', + 'tax_exemption_reason', + 'tax_exemption_note', + 'vat_pricing_mode', + ]; + + protected $casts = [ + 'tax_liable' => 'boolean', + 'vat_rate' => 'integer', ]; } diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index 3055e94..8da535c 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -4,6 +4,7 @@ namespace App\Resources; use App\Enumerations\ParticipationFeeType; use App\Enumerations\ParticipationType; +use App\Enumerations\VatPricingMode; use App\Models\Event; use App\ValueObjects\Amount; use DateTime; @@ -342,7 +343,13 @@ class EventResource extends JsonResource{ $basicFee = $basicFee->multiply(0.5); } - return $basicFee; + // Bei bestehender USt-Pflicht und Netto-Preismodus die USt aufschlagen; gespeichert wird immer der + // finale Brutto-Betrag. Bei "inklusive" oder ohne Steuerpflicht bleibt der Beitrag unverändert. + if ($this->event->tax_liable && $this->event->vat_pricing_mode === VatPricingMode::AddOn->value) { + $basicFee = $basicFee->multiply(1 + $this->event->vat_rate / 100); + } + + return new Amount(round($basicFee->getAmount(), 2), 'Euro'); } } diff --git a/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php b/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php new file mode 100644 index 0000000..d72fa77 --- /dev/null +++ b/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php @@ -0,0 +1,29 @@ +boolean('tax_liable')->default(false); + $table->unsignedTinyInteger('vat_rate')->default(0); + $table->string('tax_exemption_reason')->nullable(); + $table->text('tax_exemption_note')->nullable(); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn([ + 'tax_liable', + 'vat_rate', + 'tax_exemption_reason', + 'tax_exemption_note', + ]); + }); + } +}; diff --git a/tests/Feature/TenantTaxInformationTest.php b/tests/Feature/TenantTaxInformationTest.php new file mode 100644 index 0000000..abced21 --- /dev/null +++ b/tests/Feature/TenantTaxInformationTest.php @@ -0,0 +1,131 @@ +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); + } + + private function runAction(bool $taxLiable, int $vatRate, ?string $reason, ?string $note = null, string $mode = 'inclusive'): object + { + return new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $this->tenant, + taxLiable: $taxLiable, + vatRate: $vatRate, + exemptionReason: $reason ? TaxExemptionReason::where('slug', $reason)->first() : null, + exemptionNote: $note, + vatPricingMode: VatPricingMode::from($mode), + ))->execute(); + } + + public function test_taxable_sets_rate_and_clears_exemption(): void + { + $this->tenant->update(['tax_exemption_reason' => 'small_business', 'tax_exemption_note' => 'alt']); + + $response = $this->runAction(taxLiable: true, vatRate: 7, reason: null); + + $this->assertTrue($response->success); + + $fresh = $this->tenant->fresh(); + $this->assertTrue($fresh->tax_liable); + $this->assertSame(7, $fresh->vat_rate); + $this->assertNull($fresh->tax_exemption_reason); + $this->assertNull($fresh->tax_exemption_note); + } + + public function test_exemption_reason_persists_and_clears_rate(): void + { + $response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'youth_welfare'); + + $this->assertTrue($response->success); + + $fresh = $this->tenant->fresh(); + $this->assertFalse($fresh->tax_liable); + $this->assertSame(0, $fresh->vat_rate); + $this->assertSame('youth_welfare', $fresh->tax_exemption_reason); + $this->assertNull($fresh->tax_exemption_note); + } + + public function test_invalid_exemption_reason_fails(): void + { + $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); + } + + public function test_custom_reason_requires_note(): void + { + $missing = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: ' '); + $this->assertFalse($missing->success); + + $ok = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: 'Individueller Grund'); + $this->assertTrue($ok->success); + $this->assertSame('Individueller Grund', $this->tenant->fresh()->tax_exemption_note); + } + + public function test_vat_rate_is_clamped(): void + { + $this->runAction(taxLiable: true, vatRate: 250, reason: null); + $this->assertSame(100, $this->tenant->fresh()->vat_rate); + } + + public function test_pricing_mode_persists_when_liable(): void + { + $this->runAction(taxLiable: true, vatRate: 19, reason: null, mode: 'add_on'); + $this->assertSame('add_on', $this->tenant->fresh()->vat_pricing_mode); + } + + public function test_pricing_mode_resets_to_inclusive_when_exempt(): void + { + $this->runAction(taxLiable: false, vatRate: 0, reason: 'small_business', mode: 'add_on'); + $this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode); + } + + public function test_invoice_text_per_reason(): void + { + $this->assertSame( + 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + TaxExemptionReason::where('slug', 'small_business')->first()->invoiceText(), + ); + $this->assertSame( + 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', + TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(), + ); + // Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises. + $this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text')); + } +}