From c17facf063bdc7f8ce4ad2d404ee400b36621333 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 | 64 +++++
.../UpdateTenantTaxRequest.php | 18 ++
.../UpdateTenantTaxResponse.php | 9 +
.../ManagedTenantTaxGetController.php | 25 ++
.../ManagedTenantTaxUpdateController.php | 32 +++
.../Controllers/TenantTaxGetController.php | 23 ++
.../Controllers/TenantTaxUpdateController.php | 30 +++
app/Domains/Admin/Routes/api.php | 20 +-
.../Admin/Views/Partials/TenantTaxInfo.vue | 223 ++++++++++++++++++
app/Domains/Admin/Views/TenantData.vue | 6 +
app/Domains/Admin/Views/TenantEdit.vue | 6 +
app/Enumerations/TaxExemptionReason.php | 67 ++++++
app/Models/Tenant.php | 15 +-
..._150000_add_tax_information_to_tenants.php | 29 +++
tests/Feature/TenantTaxInformationTest.php | 119 ++++++++++
15 files changed, 679 insertions(+), 7 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..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 @@
+
+
+
+
+
+
+ | Steuerpflicht |
+ {{ isLiable ? 'Ja – Umsatzsteuer wird erhoben' : 'Nein – umsatzsteuerbefreit' }} |
+
+
+ | USt-Satz |
+ {{ form.vat_rate || 0 }} % |
+
+
+ | Befreiungsgrund |
+ {{ reasonLabel }} |
+
+
+ | Rechnungshinweis |
+ {{ invoiceHint }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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'));
+ }
+}