From 6b33c1b4ddc941dbedf1d492c1e712037173369c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Thu, 6 Aug 2026 10:15:46 +0200
Subject: [PATCH 01/12] Added tax support
---
.env.example | 2 +
.../CostUnitInvalidBillingDeadlineMail.php | 55 +++++++++++++++++++
app/Tasks/CloseCostUnit.php | 23 ++++++++
config/app.php | 2 +
...st_unit_invalid_billing_deadline.blade.php | 16 ++++++
version | 2 +-
6 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 app/Mail/AdminMails/CostUnitInvalidBillingDeadlineMail.php
create mode 100644 resources/views/emails/admin/cost_unit_invalid_billing_deadline.blade.php
diff --git a/.env.example b/.env.example
index c0660ea..f3dbb27 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,8 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
+APP_ADMIN_MAIL=
+
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
diff --git a/app/Mail/AdminMails/CostUnitInvalidBillingDeadlineMail.php b/app/Mail/AdminMails/CostUnitInvalidBillingDeadlineMail.php
new file mode 100644
index 0000000..6e499c8
--- /dev/null
+++ b/app/Mail/AdminMails/CostUnitInvalidBillingDeadlineMail.php
@@ -0,0 +1,55 @@
+costUnitId),
+ );
+ }
+
+ /**
+ * Get the message content definition.
+ */
+ public function content(): Content
+ {
+ return new Content(
+ view: 'emails.admin.cost_unit_invalid_billing_deadline',
+ with: [
+ 'costUnitId' => $this->costUnitId,
+ 'costUnitName' => $this->costUnitName,
+ 'invalidBillingDeadline' => $this->invalidBillingDeadline,
+ ],
+ );
+ }
+
+ /**
+ * Get the attachments for the message.
+ *
+ * @return array
+ */
+ public function attachments(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Tasks/CloseCostUnit.php b/app/Tasks/CloseCostUnit.php
index 6854426..2e80dc3 100644
--- a/app/Tasks/CloseCostUnit.php
+++ b/app/Tasks/CloseCostUnit.php
@@ -6,9 +6,11 @@ use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsComm
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateCommand;
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateRequest;
+use App\Mail\AdminMails\CostUnitInvalidBillingDeadlineMail;
use App\Models\CostUnit;
use App\Repositories\CostUnitRepository;
use App\ValueObjects\Amount;
+use Illuminate\Support\Facades\Mail;
class CloseCostUnit implements CronTask {
public function handle(): void
@@ -33,6 +35,27 @@ class CloseCostUnit implements CronTask {
}
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
+ if (false === $billingEndTime) {
+ $billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd);
+ }
+
+ if (false === $billingEndTime) {
+ $recipients = config('app.admin_mail');
+ if (null === $recipients) {
+ $recipients = CostUnit::where('id', $costUnit['id'])->first()
+ ?->treasurers()->pluck('email')->all() ?? [];
+ }
+
+ if (!empty($recipients)) {
+ Mail::to($recipients)->send(new CostUnitInvalidBillingDeadlineMail(
+ costUnitId: $costUnit['id'],
+ costUnitName: $costUnit['name'] ?? null,
+ invalidBillingDeadline: $billingEnd,
+ ));
+ }
+
+ continue;
+ }
$billingEndTime->setTime(0,0,0);
$billingEndTime->add(new \DateInterval('P1D'));
diff --git a/config/app.php b/config/app.php
index 99d08ae..70f221a 100644
--- a/config/app.php
+++ b/config/app.php
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'),
+ 'admin_mail' => env('APP_ADMIN_MAIL'),
+
/*
|--------------------------------------------------------------------------
| Application Environment
diff --git a/resources/views/emails/admin/cost_unit_invalid_billing_deadline.blade.php b/resources/views/emails/admin/cost_unit_invalid_billing_deadline.blade.php
new file mode 100644
index 0000000..95c95c6
--- /dev/null
+++ b/resources/views/emails/admin/cost_unit_invalid_billing_deadline.blade.php
@@ -0,0 +1,16 @@
+
+
+
+
+ Für die Kostenstelle "{{ $costUnitName }}" (ID {{ $costUnitId }}) konnte das Abrechnungsende
+ nicht verarbeitet werden.
+
+
+ Der hinterlegte Wert "{{ $invalidBillingDeadline }}" ist weder ein gültiges Datum noch ein
+ Datum mit Uhrzeit. Die automatische Verarbeitung dieser Kostenstelle wurde deshalb übersprungen.
+
+
+ Bitte prüft und korrigiert das Abrechnungsende dieser Kostenstelle.
+
+
+
+
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'));
+ }
+}
From 5031d3bde0a639281644345553c8f452a31261cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Tue, 11 Aug 2026 14:54:33 +0200
Subject: [PATCH 04/12] Tax excpetion texts
---
app/Enumerations/TaxExemptionReason.php | 12 ++-
app/Views/Components/RichSelectBox.vue | 75 ++++++++++++-----
...06_170020_refine_tax_exemption_reasons.php | 81 +++++++++++++++++++
...fault_tax_exemption_reason_jugendhilfe.php | 20 +++++
tests/Feature/TenantTaxInformationTest.php | 24 ++++--
5 files changed, 179 insertions(+), 33 deletions(-)
create mode 100644 database/migrations/2026_08_06_170020_refine_tax_exemption_reasons.php
create mode 100644 database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
diff --git a/app/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php
index 10da598..9493264 100644
--- a/app/Enumerations/TaxExemptionReason.php
+++ b/app/Enumerations/TaxExemptionReason.php
@@ -14,12 +14,14 @@ use App\Scopes\CommonModel;
* @property string $name
* @property string $invoice_text
* @property bool $requires_note
+ * @property int $sort_order
*/
class TaxExemptionReason extends CommonModel
{
- public const string SMALL_BUSINESS = 'small_business';
- public const string EDUCATION = 'education';
- public const string YOUTH_WELFARE = 'youth_welfare';
+ public const string USTG_19 = 'ustg_19';
+ public const string USTG_4_24 = 'ustg_4_24';
+ public const string USTG_4_25A = 'ustg_4_25a';
+ public const string USTG_4_22A = 'ustg_4_22a';
public const string CUSTOM = 'custom';
protected $table = 'tax_exemption_reasons';
@@ -32,10 +34,12 @@ class TaxExemptionReason extends CommonModel
'name',
'invoice_text',
'requires_note',
+ 'sort_order',
];
protected $casts = [
'requires_note' => 'boolean',
+ 'sort_order' => 'integer',
];
/**
@@ -55,7 +59,7 @@ class TaxExemptionReason extends CommonModel
*/
public static function options(): array
{
- return self::all()
+ return self::orderBy('sort_order')->get()
->map(static fn (self $reason): array => [
'value' => $reason->slug,
'label' => $reason->name,
diff --git a/app/Views/Components/RichSelectBox.vue b/app/Views/Components/RichSelectBox.vue
index 7513d8e..c168065 100644
--- a/app/Views/Components/RichSelectBox.vue
+++ b/app/Views/Components/RichSelectBox.vue
@@ -1,9 +1,11 @@
@@ -67,20 +92,28 @@ onBeforeUnmount(() => {
▾
-
-
+
-
- {{ option.label }}
-
-
+
+
+ {{ option.label }}
+
+
+
@@ -126,11 +159,8 @@ onBeforeUnmount(() => {
}
.rich-select__list {
- position: absolute;
- z-index: 50;
- top: calc(100% + 4px);
- left: 0;
- right: 0;
+ position: fixed;
+ z-index: 1000;
margin: 0;
padding: 4px;
list-style: none;
@@ -140,6 +170,7 @@ onBeforeUnmount(() => {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 260px;
overflow: auto;
+ box-sizing: border-box;
}
.rich-select__option {
diff --git a/database/migrations/2026_08_06_170020_refine_tax_exemption_reasons.php b/database/migrations/2026_08_06_170020_refine_tax_exemption_reasons.php
new file mode 100644
index 0000000..da9b672
--- /dev/null
+++ b/database/migrations/2026_08_06_170020_refine_tax_exemption_reasons.php
@@ -0,0 +1,81 @@
+unsignedSmallInteger('sort_order')->default(0);
+ });
+
+ // § 19 – nur Slug + Sortierung
+ DB::table('tax_exemption_reasons')->where('slug', 'small_business')->update([
+ 'slug' => 'ustg_19',
+ 'sort_order' => 10,
+ ]);
+
+ // § 4 Nr. 25 Buchst. a – präzises Zitat (Durchführung kultureller/sportlicher Veranstaltungen)
+ DB::table('tax_exemption_reasons')->where('slug', 'youth_welfare')->update([
+ 'slug' => 'ustg_4_25a',
+ 'name' => 'Jugendhilfe – Veranstaltungen (§ 4 Nr. 25 Buchst. a UStG)',
+ 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 25 Buchst. a UStG von der Umsatzsteuer befreit.',
+ 'sort_order' => 30,
+ ]);
+
+ // § 4 Nr. 22 Buchst. a – korrekt benannt, ans Ende (vor Freitext)
+ DB::table('tax_exemption_reasons')->where('slug', 'education')->update([
+ 'slug' => 'ustg_4_22a',
+ 'name' => 'Vorträge/Kurse (§ 4 Nr. 22 Buchst. a UStG)',
+ 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.',
+ 'sort_order' => 40,
+ ]);
+
+ // Freitext ans Ende
+ DB::table('tax_exemption_reasons')->where('slug', 'custom')->update([
+ 'sort_order' => 50,
+ ]);
+
+ // § 4 Nr. 24 (Jugendherbergen) – neu
+ $now = now();
+ DB::table('tax_exemption_reasons')->insertOrIgnore([
+ [
+ 'slug' => 'ustg_4_24',
+ 'name' => 'Jugendherbergen (§ 4 Nr. 24 UStG)',
+ 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 24 UStG von der Umsatzsteuer befreit.',
+ 'requires_note' => false,
+ 'sort_order' => 20,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_24')->delete();
+ DB::table('tax_exemption_reasons')->where('slug', 'ustg_19')->update(['slug' => 'small_business']);
+ DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_25a')->update([
+ '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.',
+ ]);
+ DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_22a')->update([
+ '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.',
+ ]);
+
+ Schema::table('tax_exemption_reasons', function (Blueprint $table) {
+ $table->dropColumn('sort_order');
+ });
+ }
+};
diff --git a/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php b/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
new file mode 100644
index 0000000..10cce5b
--- /dev/null
+++ b/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
@@ -0,0 +1,20 @@
+tenant->update(['tax_exemption_reason' => 'small_business', 'tax_exemption_note' => 'alt']);
+ $this->tenant->update(['tax_exemption_reason' => 'ustg_19', 'tax_exemption_note' => 'alt']);
$response = $this->runAction(taxLiable: true, vatRate: 7, reason: null);
@@ -67,14 +67,14 @@ class TenantTaxInformationTest extends TestCase
public function test_exemption_reason_persists_and_clears_rate(): void
{
- $response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'youth_welfare');
+ $response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'ustg_4_25a');
$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->assertSame('ustg_4_25a', $fresh->tax_exemption_reason);
$this->assertNull($fresh->tax_exemption_note);
}
@@ -111,7 +111,7 @@ class TenantTaxInformationTest extends TestCase
public function test_pricing_mode_resets_to_inclusive_when_exempt(): void
{
- $this->runAction(taxLiable: false, vatRate: 0, reason: 'small_business', mode: 'add_on');
+ $this->runAction(taxLiable: false, vatRate: 0, reason: 'ustg_19', mode: 'add_on');
$this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode);
}
@@ -119,13 +119,23 @@ class TenantTaxInformationTest extends TestCase
{
$this->assertSame(
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
- TaxExemptionReason::where('slug', 'small_business')->first()->invoiceText(),
+ TaxExemptionReason::where('slug', 'ustg_19')->first()->invoiceText(),
);
$this->assertSame(
- 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
- TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(),
+ 'Die Leistung ist gemäß § 4 Nr. 25 Buchst. a UStG von der Umsatzsteuer befreit.',
+ TaxExemptionReason::where('slug', 'ustg_4_25a')->first()->invoiceText(),
+ );
+ $this->assertSame(
+ 'Die Leistung ist gemäß § 4 Nr. 24 UStG von der Umsatzsteuer befreit.',
+ TaxExemptionReason::where('slug', 'ustg_4_24')->first()->invoiceText(),
);
// Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises.
$this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text'));
}
+
+ public function test_options_are_ordered(): void
+ {
+ $slugs = array_column(TaxExemptionReason::options(), 'value');
+ $this->assertSame(['ustg_19', 'ustg_4_24', 'ustg_4_25a', 'ustg_4_22a', 'custom'], $slugs);
+ }
}
From 554166803b9f1bc635c33f4079753ef518e4eff9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Tue, 11 Aug 2026 16:01:00 +0200
Subject: [PATCH 05/12] Tax excpetion texts
---
CLAUDE.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index ed8db94..4ef37b8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,3 +15,60 @@ Routing, Mails) stehen in `.ai/conventions.md` und werden hier eingebunden — *
- [Zahlungsmodule](app/EventPaymentModules/CLAUDE.md) — interface-gesteuerte Zahlungsarten (Optionen, Config,
Anmelde-Zusammenfassung, GiroCode/Fähigkeits-Interfaces, neues Modul hinzufügen).
+
+## Umsatzsteuer / Steuerinformationen
+
+Rechtlich korrekter Begriff ist **Umsatzsteuer (USt)**, nicht MwSt. Ziel: tagesbasierte Anmelde-Rechnungen („Teilnahme
+Veranstaltung X in Gruppe Y" × Anwesenheitstage × Beitrag/Tag). Grundlage ist gebaut, die Rechnungs-PDF/-berechnung
+folgt noch.
+
+### Tenant-Einstellungen (Tab „Steuerinformationen")
+
+Felder auf `tenants` (Self-Service `TenantData.vue` + verwaltet `TenantEdit.vue`, Partial `TenantTaxInfo.vue`,
+Controller `TenantTax*` / `ManagedTenantTax*`, Action `UpdateTenantTax`):
+
+- `tax_liable` (bool) — ob USt-Pflicht besteht.
+- `vat_rate` (int, %) — USt-Satz, nur relevant wenn `tax_liable` (0–100, geclamped).
+- `vat_pricing_mode` (Enum `App\Enumerations\VatPricingMode`, natives PHP-Enum: `inclusive` | `add_on`).
+- `tax_exemption_reason` (FK → `tax_exemption_reasons.slug`, nullable) — Grund wenn **nicht** steuerpflichtig.
+ DB-Default neuer Tenants: `ustg_4_25a` (Jugendhilfe).
+- `tax_exemption_note` (text, nullable) — Freitext, nur bei Grund `custom` (`requires_note`).
+
+### Preismodus — **das Kernverhalten**
+
+Es wird **immer der finale Brutto-Beitrag** pro Anmeldung gespeichert (`EventParticipant.amount`, keine USt-Spalten am
+Teilnehmer). Der Modus wirkt **nur bei der Preisermittlung im Anmeldeprozess**:
+
+- **`inclusive`** (Brutto / „zusammenfassen", Supermarkt-Prinzip, Default): der konfigurierte Beitrag **ist** der
+ Endpreis, USt ist bereits enthalten → Preis bleibt gleich; die USt wird erst **auf der Rechnung herausgerechnet**:
+ `USt = brutto × Satz / (100 + Satz)`, `netto = brutto − USt`.
+- **`add_on`** (Netto / „aufaddieren"): der konfigurierte Beitrag ist netto → Endpreis = `Beitrag × (1 + Satz/100)`. Die
+ USt wird bei der Anmeldung **aufgeschlagen** → Teilnehmer zahlt mehr.
+
+Umsetzung in `App\Resources\EventResource::calculateAmount()`: nach allen Multiplikatoren (Early-Bird, `pay_per_day`,
+Geschwister) gilt
+`if ($event->tax_liable && $event->vat_pricing_mode === VatPricingMode::AddOn->value) $fee->multiply(1 + $event->vat_rate/100);`
+danach `round(…, 2)`. Bei `inclusive`/nicht steuerpflichtig bleibt der Beitrag unverändert.
+
+### Event-Snapshot (unveränderlich)
+
+Bei Event-Anlage (`CreateEventCommand::execute()`) werden `tax_liable`, `vat_rate`, `vat_pricing_mode`,
+`tax_exemption_reason`, `tax_exemption_note` aus dem Tenant **auf das Event kopiert und dort eingefroren** (Spalten auf
+`events`). Preisermittlung und spätere Rechnungen lesen vom Event, nicht vom Tenant → spätere Tenant-Änderungen berühren
+bestehende Events/Rechnungen nicht.
+
+### Befreiungsgründe (DB-gestützt)
+
+Tabelle `tax_exemption_reasons`, Model `App\Enumerations\TaxExemptionReason` (Eloquent, `slug` PK; `name`,
+`invoice_text`, `requires_note`, `sort_order`). `options()` liefert `{value,label,text}` sortiert nach `sort_order`. FK
+von `tenants` **und** `events`. Seed in Migration `170010`, verfeinert in `170020`. Gründe (in Reihenfolge):
+`ustg_19` (§ 19 Kleinunternehmer), `ustg_4_24` (§ 4 Nr. 24 Jugendherbergen),
+`ustg_4_25a` (§ 4 Nr. 25 Buchst. a — Jugendhilfe-Veranstaltungen, Default), `ustg_4_22a` (§ 4 Nr. 22 Buchst. a —
+Vorträge/Kurse), `custom` (Freitext). Jeder Grund kapselt den Rechnungs-Pflichthinweis nach **§ 14 Abs. 4 Nr. 8 UStG**
+(`invoice_text`; bei `custom` der Tenant-Freitext via `invoiceText(?note)`). Gemeinnützigkeit allein befreit **nicht** —
+es braucht immer einen konkreten Rechtsgrund.
+
+### Noch offen (nächster Schritt)
+
+Die eigentliche **Rechnung**: tagesbasierte Positionen, USt aus dem gespeicherten Brutto herausrechnen (Satz vom
+Event-Snapshot); bei Befreiung kein USt-Ausweis, stattdessen der Pflichthinweis des Grundes.
From 9581176a0ceb2f24eb44c716ffea002fe2796959 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Wed, 12 Aug 2026 15:01:35 +0200
Subject: [PATCH 06/12] Additional event informations can be added
---
.../SetParticipationOptionsCommand.php | 111 +++++++++++++
.../SetParticipationOptionsRequest.php | 19 +++
.../SetParticipationOptionsResponse.php | 15 ++
.../Event/Actions/SignUp/SignUpCommand.php | 68 ++++++++
.../Event/Actions/SignUp/SignUpRequest.php | 2 +
.../Event/Controllers/DetailsController.php | 13 ++
.../Event/Controllers/SignupController.php | 1 +
app/Domains/Event/Routes/api.php | 1 +
app/Domains/Event/Views/Partials/Overview.vue | 30 ++--
.../Views/Partials/ParticipationOptions.vue | 155 ++++++++++++++++++
.../Views/Partials/SignUpForm/SignupForm.vue | 22 ++-
.../SignUpForm/composables/stepFlow.js | 45 +++++
.../SignUpForm/composables/useSignupForm.js | 7 +-
.../Partials/SignUpForm/steps/StepAddons.vue | 6 +-
.../SignUpForm/steps/StepAllergies.vue | 4 +-
.../steps/StepParticipationOptions.vue | 48 ++++++
.../SignUpForm/steps/StepPaymentMethod.vue | 4 +-
.../SignUpForm/steps/StepPhotoPermissions.vue | 11 +-
.../SignUpForm/steps/StepRegistrationMode.vue | 6 +-
.../Partials/SignUpForm/steps/StepSummary.vue | 18 +-
app/Models/Event.php | 3 +
app/Models/EventParticipant.php | 7 +
app/Models/EventParticipantOption.php | 31 ++++
.../EventParticipantOptionResource.php | 24 +++
app/Resources/EventParticipantResource.php | 2 +
app/Resources/EventResource.php | 3 +
...10_add_participation_options_to_events.php | 23 +++
...40020_create_event_participant_options.php | 35 ++++
28 files changed, 675 insertions(+), 39 deletions(-)
create mode 100644 app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php
create mode 100644 app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php
create mode 100644 app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php
create mode 100644 app/Domains/Event/Views/Partials/ParticipationOptions.vue
create mode 100644 app/Domains/Event/Views/Partials/SignUpForm/composables/stepFlow.js
create mode 100644 app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue
create mode 100644 app/Models/EventParticipantOption.php
create mode 100644 app/Resources/EventParticipantOptionResource.php
create mode 100644 database/migrations/2026_08_12_140010_add_participation_options_to_events.php
create mode 100644 database/migrations/2026_08_12_140020_create_event_participant_options.php
diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php
new file mode 100644
index 0000000..8e7696d
--- /dev/null
+++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php
@@ -0,0 +1,111 @@
+request->event->participation_options = $this->normalize($this->request->questions);
+ $this->request->event->save();
+
+ $response->success = true;
+ return $response;
+ }
+
+ /**
+ * Normalisiert die rohe Fragen-Definition: trimmt Labels, generiert stabile Keys/Values und verwirft
+ * unvollständige Fragen (kein Label oder keine gültige Option). Keys/Values sind innerhalb ihres
+ * Geltungsbereichs eindeutig, damit Antworten robust referenzieren können.
+ *
+ * @param array> $questions
+ * @return array}>
+ */
+ private function normalize(array $questions): array
+ {
+ $normalized = [];
+ $usedKeys = [];
+
+ foreach ($questions as $question) {
+ $label = trim((string)($question['label'] ?? ''));
+ if ($label === '') {
+ continue;
+ }
+
+ $options = $this->normalizeOptions($question['options'] ?? []);
+ if ($options === []) {
+ continue;
+ }
+
+ $key = $this->uniqueSlug((string)($question['key'] ?? ''), $label, $usedKeys);
+ $usedKeys[] = $key;
+
+ $normalized[] = [
+ 'key' => $key,
+ 'label' => $label,
+ 'options' => $options,
+ ];
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * @param array> $options
+ * @return array
+ */
+ private function normalizeOptions(mixed $options): array
+ {
+ if (!is_array($options)) {
+ return [];
+ }
+
+ $normalized = [];
+ $usedValues = [];
+
+ foreach ($options as $option) {
+ $label = trim((string)($option['label'] ?? ''));
+ if ($label === '') {
+ continue;
+ }
+
+ $value = $this->uniqueSlug((string)($option['value'] ?? ''), $label, $usedValues);
+ $usedValues[] = $value;
+
+ $normalized[] = ['value' => $value, 'label' => $label];
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Liefert einen eindeutigen Slug: bevorzugt den bestehenden Wert (Bestandsschutz für schon gespeicherte
+ * Fragen/Antworten), sonst aus dem Label abgeleitet; Kollisionen werden durchnummeriert.
+ *
+ * @param array $used
+ */
+ private function uniqueSlug(string $existing, string $label, array $used): string
+ {
+ $base = $existing !== '' ? $existing : Str::slug($label);
+ if ($base === '') {
+ $base = 'option';
+ }
+
+ $candidate = $base;
+ $suffix = 2;
+ while (in_array($candidate, $used, true)) {
+ $candidate = $base . '-' . $suffix;
+ $suffix++;
+ }
+
+ return $candidate;
+ }
+}
diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php
new file mode 100644
index 0000000..ad89323
--- /dev/null
+++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php
@@ -0,0 +1,19 @@
+> Rohe Fragen-Definition aus dem Admin-Panel. */
+ public array $questions;
+
+ public function __construct(Event $event, array $questions)
+ {
+ $this->event = $event;
+ $this->questions = $questions;
+ }
+}
diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php
new file mode 100644
index 0000000..603018c
--- /dev/null
+++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php
@@ -0,0 +1,15 @@
+success = false;
+ $this->message = null;
+ }
+}
diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
index f4ad093..518fbad 100644
--- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php
+++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
@@ -27,6 +27,14 @@ class SignUpCommand {
return $response;
}
+ // Teilnahmeoptionen (event-spezifische Pflicht-Auswahl) gegen die Event-Definition absichern.
+ $participationOptionRows = $this->buildParticipationOptionRows();
+ if ($participationOptionRows === null) {
+ $response->success = false;
+ $response->message = 'Bitte alle Auswahlfelder ausfüllen.';
+ return $response;
+ }
+
$eatingHabit = match ($this->request->eating_habit) {
'vegan' => EatingHabit::EATING_HABIT_VEGAN,
'vegetarian' => EatingHabit::EATING_HABIT_VEGETARIAN,
@@ -80,8 +88,68 @@ class SignUpCommand {
]
);
+ $this->persistParticipationOptions($response->participant, $participationOptionRows);
+
$response->success = true;
return $response;
}
+ /**
+ * Validiert die Antworten gegen die am Event definierten Teilnahmeoptionen und erzeugt die zu speichernden
+ * Zeilen mit Label-Snapshots. Gibt null zurück, wenn eine Pflicht-Frage unbeantwortet oder eine Antwort
+ * ungültig ist (Absicherung gegen manipulierte Payloads).
+ *
+ * @return array|null
+ */
+ private function buildParticipationOptionRows(): ?array
+ {
+ $rows = [];
+
+ foreach ($this->request->event->participation_options ?? [] as $question) {
+ $key = $question['key'] ?? null;
+ if ($key === null) {
+ continue;
+ }
+
+ $value = $this->request->participationOptions[$key] ?? null;
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ $option = null;
+ foreach ($question['options'] ?? [] as $candidate) {
+ if (($candidate['value'] ?? null) === $value) {
+ $option = $candidate;
+ break;
+ }
+ }
+
+ if ($option === null) {
+ return null;
+ }
+
+ $rows[] = [
+ 'question_key' => $key,
+ 'question_label' => $question['label'] ?? $key,
+ 'value' => $value,
+ 'value_label' => $option['label'] ?? $value,
+ ];
+ }
+
+ return $rows;
+ }
+
+ /**
+ * @param array $rows
+ */
+ private function persistParticipationOptions(\App\Models\EventParticipant $participant, array $rows): void
+ {
+ foreach ($rows as $row) {
+ $participant->selectedOptions()->create(array_merge($row, [
+ 'tenant' => $this->request->event->tenant,
+ 'event_id' => $this->request->event->id,
+ ]));
+ }
+ }
+
}
diff --git a/app/Domains/Event/Actions/SignUp/SignUpRequest.php b/app/Domains/Event/Actions/SignUp/SignUpRequest.php
index c64979b..de08ed7 100644
--- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php
+++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php
@@ -46,6 +46,8 @@ class SignUpRequest {
public Amount $amount,
public ?string $paymentMethod,
public array $paymentOptions = [],
+ /** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */
+ public array $participationOptions = [],
) {
}
}
diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php
index b64c281..2bb71d2 100644
--- a/app/Domains/Event/Controllers/DetailsController.php
+++ b/app/Domains/Event/Controllers/DetailsController.php
@@ -4,6 +4,8 @@ namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\SetParticipationFees\SetParticipationFeesCommand;
use App\Domains\Event\Actions\SetParticipationFees\SetParticipationFeesRequest;
+use App\Domains\Event\Actions\SetParticipationOptions\SetParticipationOptionsCommand;
+use App\Domains\Event\Actions\SetParticipationOptions\SetParticipationOptionsRequest;
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsCommand;
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsRequest;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
@@ -142,6 +144,17 @@ class DetailsController extends CommonController {
return response()->json(['status' => $response->success ? 'success' : 'error']);
}
+ public function updateParticipationOptions(int $eventId, Request $request): JsonResponse
+ {
+ $event = $this->events->getById($eventId);
+
+ $setRequest = new SetParticipationOptionsRequest($event, (array)$request->input('questions', []));
+ $setCommand = new SetParticipationOptionsCommand($setRequest);
+ $response = $setCommand->execute();
+
+ return response()->json(['status' => $response->success ? 'success' : 'error', 'message' => $response->message]);
+ }
+
public function downloadPdfList(string $eventId, string $listType, Request $request): Response
{
$event = $this->events->getByIdentifier($eventId);
diff --git a/app/Domains/Event/Controllers/SignupController.php b/app/Domains/Event/Controllers/SignupController.php
index 643d0d6..ad776a1 100644
--- a/app/Domains/Event/Controllers/SignupController.php
+++ b/app/Domains/Event/Controllers/SignupController.php
@@ -142,6 +142,7 @@ class SignupController extends CommonController {
$amount,
$registrationData['paymentMethod'] ?? null,
(array)($registrationData['paymentOptions'] ?? []),
+ (array)($registrationData['participationOptions'] ?? []),
);
$signupCommand = new SignUpCommand($signupRequest);
diff --git a/app/Domains/Event/Routes/api.php b/app/Domains/Event/Routes/api.php
index 34cb9b6..760e935 100644
--- a/app/Domains/Event/Routes/api.php
+++ b/app/Domains/Event/Routes/api.php
@@ -46,6 +46,7 @@ Route::prefix('api/v1')
Route::post('/participation-fees', [DetailsController::class, 'updateParticipationFees']);
Route::post('/common-settings', [DetailsController::class, 'updateCommonSettings']);
Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']);
+ Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']);
});
diff --git a/app/Domains/Event/Views/Partials/Overview.vue b/app/Domains/Event/Views/Partials/Overview.vue
index 920e544..26607b7 100644
--- a/app/Domains/Event/Views/Partials/Overview.vue
+++ b/app/Domains/Event/Views/Partials/Overview.vue
@@ -1,15 +1,16 @@
+
+
+
+
Teilnahmeoptionen
+
+ Pflicht-Auswahlfragen für die Anmeldung (z. B. Kurs oder Stufe). Je Frage ist genau eine Option wählbar.
+ Ohne Fragen wird dieser Schritt in der Anmeldung übersprungen.
+
+ Für diese Veranstaltung sind noch keine Teilnahmeoptionen angelegt. Sobald du unter
+ Übersicht → Teilnahmeoptionen Fragen definierst, werden die Teilnehmenden hier nach
+ ihrer Auswahl gruppiert.
+
+
+ Zu den Teilnahmeoptionen
+
+
+
+
+
diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue
index 93ea9be..0479493 100644
--- a/app/Domains/Event/Views/Partials/ParticipantsList.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue
@@ -20,6 +20,11 @@ const props = defineProps({
listType: '',
}),
},
+ // Steuert, ob je Gruppe der „E-Mail an Gruppe senden"-Button angezeigt wird.
+ showGroupMail: {
+ type: Boolean,
+ default: true,
+ },
});
const today = format(new Date(), "yyyy-MM-dd");
@@ -404,7 +409,8 @@ function mailToGroup(groupKey) {
27 Jahre und älter: {{ getAgeCounts(participants)['27+'] ?? 0 }}
-
+
diff --git a/app/Domains/Event/Views/Partials/ParticipationOptions.vue b/app/Domains/Event/Views/Partials/ParticipationOptions.vue
index ac742ef..f57333a 100644
--- a/app/Domains/Event/Views/Partials/ParticipationOptions.vue
+++ b/app/Domains/Event/Views/Partials/ParticipationOptions.vue
@@ -14,12 +14,13 @@ const props = defineProps({
// neue Einträge bekommen serverseitig beim Speichern einen stabilen Slug.
const questions = ref((props.event.participationOptions ?? []).map(q => ({
key: q.key ?? '',
+ title: q.title ?? '',
label: q.label ?? '',
options: (q.options ?? []).map(o => ({value: o.value ?? '', label: o.label ?? ''})),
})))
function addQuestion() {
- questions.value.push({key: '', label: '', options: [{value: '', label: ''}, {value: '', label: ''}]})
+ questions.value.push({key: '', title: '', label: '', options: [{value: '', label: ''}, {value: '', label: ''}]})
}
function removeQuestion(index) {
@@ -36,8 +37,8 @@ function removeOption(question, index) {
function validate() {
for (const question of questions.value) {
- if (question.label.trim() === '') {
- toast.error('Bitte für jede Frage eine Beschriftung angeben.')
+ if (question.title.trim() === '') {
+ toast.error('Bitte für jede Frage einen Titel angeben.')
return false
}
@@ -83,12 +84,23 @@ async function save() {
+ Für diese Veranstaltung sind noch keine Zusatzoptionen angelegt. Sobald du unter
+ Übersicht → Zusatzoptionen Zusätze definierst, werden die Teilnehmenden hier nach
+ ihren gebuchten Zusätzen gruppiert.
+
+
+ Zu den Zusatzoptionen
+
+
+
+
+
diff --git a/app/Repositories/EventParticipantRepository.php b/app/Repositories/EventParticipantRepository.php
index ea184ab..efba1e6 100644
--- a/app/Repositories/EventParticipantRepository.php
+++ b/app/Repositories/EventParticipantRepository.php
@@ -141,6 +141,44 @@ class EventParticipantRepository {
return $groups;
}
+ /**
+ * Gruppiert die (aktiven) Teilnehmenden nach ihren gebuchten Zusätzen (Event-Addons). Da Addons Ja/Nein
+ * sind, entsteht eine Gruppe je Zusatz (Überschrift = Addon-Titel) mit den Teilnehmenden, die ihn gebucht
+ * haben; ein Teilnehmer kann in mehreren Gruppen auftauchen. Nur nicht-leere Gruppen. Die Reihenfolge folgt
+ * der Event-Definition; nachträglich entfernte Addons werden über den Snapshot-Titel weiterhin abgedeckt.
+ */
+ public function groupByAddon(Event $event, Request $request, ?string $filter = null): array
+ {
+ $allParticipants = $this->getForList($event, $request);
+
+ // Gruppen-Reihenfolge aus der Event-Definition vorbelegen (leere Gruppen werden am Ende verworfen).
+ $groups = [];
+ foreach ($event->addons ?? [] as $addon) {
+ $title = $addon['title'] ?? ($addon['key'] ?? null);
+ if ($title !== null) {
+ $groups[$title] = [];
+ }
+ }
+
+ foreach ($allParticipants as $participant) {
+ foreach ($participant['selectedAddons'] ?? [] as $selectedAddon) {
+ $title = $selectedAddon['title'] ?? ($selectedAddon['addonKey'] ?? null);
+ if ($title === null) {
+ continue;
+ }
+ $groups[$title][] = $participant;
+ }
+ }
+
+ $groups = array_filter($groups, fn($group) => count($group) > 0);
+
+ if ($filter !== null) {
+ return array_key_exists($filter, $groups) ? [$filter => $groups[$filter]] : [];
+ }
+
+ return $groups;
+ }
+
public function getSignedOffParticipants(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request, true);
$participants = [];
From ea57f9400ef57fb688457a24061aa520e789a3b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Wed, 12 Aug 2026 17:14:35 +0200
Subject: [PATCH 11/12] Handling of event addons
---
version | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/version b/version
index c78c496..f6cdf40 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-4.6.2
+4.7.0
From 81912d24335e6ef759ca55392209a015c9555fd5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Wed, 12 Aug 2026 17:15:50 +0200
Subject: [PATCH 12/12] Handling of event addons
---
version | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/version b/version
index f6cdf40..88f1811 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-4.7.0
+4.8.0