diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php new file mode 100644 index 0000000..0357db3 --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php @@ -0,0 +1,64 @@ +request->taxLiable) { + $this->request->tenant->update([ + 'tax_liable' => true, + 'vat_rate' => $this->clampVatRate($this->request->vatRate), + 'tax_exemption_reason' => null, + 'tax_exemption_note' => null, + ]); + + $response->success = true; + $response->message = 'Steuerinformationen wurden gespeichert.'; + + return $response; + } + + $reason = TaxExemptionReason::tryFrom((string)$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->requiresNote() && $note === '') { + $response->message = 'Bitte einen Text für den Befreiungsgrund angeben.'; + + return $response; + } + + $this->request->tenant->update([ + 'tax_liable' => false, + 'vat_rate' => 0, + 'tax_exemption_reason' => $reason->value, + 'tax_exemption_note' => $reason->requiresNote() ? $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..d6a7deb --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php @@ -0,0 +1,18 @@ +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, + 'exemption_reasons' => TaxExemptionReason::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..e980cf4 --- /dev/null +++ b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php @@ -0,0 +1,32 @@ +adminTenants->findBySlug($slug); + + $action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $tenant, + taxLiable: $request->boolean('tax_liable'), + vatRate: (int)$request->input('vat_rate', 0), + exemptionReason: $request->input('tax_exemption_reason'), + exemptionNote: $request->input('tax_exemption_note'), + )); + + $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..b4d3115 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxGetController.php @@ -0,0 +1,23 @@ +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, + 'exemption_reasons' => TaxExemptionReason::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..db305f1 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php @@ -0,0 +1,30 @@ +tenant, + taxLiable: $request->boolean('tax_liable'), + vatRate: (int)$request->input('vat_rate', 0), + exemptionReason: $request->input('tax_exemption_reason'), + exemptionNote: $request->input('tax_exemption_note'), + )); + + $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..a4f78cb --- /dev/null +++ b/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue @@ -0,0 +1,223 @@ + + + + + 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/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php new file mode 100644 index 0000000..eac188b --- /dev/null +++ b/app/Enumerations/TaxExemptionReason.php @@ -0,0 +1,67 @@ + 'Kleinunternehmer (§ 19 UStG)', + self::Education => 'Bildungs-/Kursleistung (§ 4 Nr. 22a UStG)', + self::YouthWelfare => 'Kinder- und Jugendhilfe (§ 4 Nr. 25 UStG)', + self::Custom => 'Sonstiger Grund (Freitext)', + }; + } + + /** + * Pflichthinweis für die Rechnung. Bei einem individuellen Grund (Custom) wird der hinterlegte + * Freitext des Tenants verwendet. + */ + public function invoiceText(?string $customNote = null): string + { + return match ($this) { + self::SmallBusiness => 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + self::Education => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.', + self::YouthWelfare => 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', + self::Custom => trim((string)$customNote), + }; + } + + /** + * Ob dieser Grund einen zusätzlichen Freitext des Tenants erfordert. + */ + public function requiresNote(): bool + { + return $this === self::Custom; + } + + /** + * Optionen für das Frontend inkl. Vorschau-Text. Bei Custom ist `text` leer, da der Text erst aus + * dem Freitext des Tenants entsteht. + * + * @return array + */ + public static function options(): array + { + return array_map( + static fn(self $reason): array => [ + 'value' => $reason->value, + 'label' => $reason->label(), + 'text' => $reason->requiresNote() ? '' : $reason->invoiceText(), + ], + self::cases(), + ); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1efd913..9d1c23e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -21,6 +21,10 @@ 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 */ class Tenant extends CommonModel { @@ -46,6 +50,15 @@ 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', + ]; + + protected $casts = [ + 'tax_liable' => 'boolean', + 'vat_rate' => 'integer', ]; } 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..3045e99 --- /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')->default('youth_welfare'); + $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..1b6eecf --- /dev/null +++ b/tests/Feature/TenantTaxInformationTest.php @@ -0,0 +1,119 @@ +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): object + { + return new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $this->tenant, + taxLiable: $taxLiable, + vatRate: $vatRate, + exemptionReason: $reason, + exemptionNote: $note, + ))->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); + $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_enum_invoice_text_per_case(): void + { + $this->assertSame( + 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + TaxExemptionReason::SmallBusiness->invoiceText(), + ); + $this->assertSame( + 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.', + TaxExemptionReason::Education->invoiceText(), + ); + $this->assertSame( + 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', + TaxExemptionReason::YouthWelfare->invoiceText(), + ); + $this->assertSame('Mein Text', TaxExemptionReason::Custom->invoiceText('Mein Text')); + } +}