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 @@
+
+
+
+
+
+
+ | Steuerpflicht |
+ {{ isLiable ? 'Ja – Umsatzsteuer wird erhoben' : 'Nein – umsatzsteuerbefreit' }} |
+
+
+ | USt-Satz |
+ {{ form.vat_rate || 0 }} % |
+
+
+ | Preismodus |
+ {{ pricingModeLabel }} |
+
+
+ | 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/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'));
+ }
+}