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/version b/version index 8ac28bf..c78c496 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.6.1 +4.6.2 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 02/12] 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 @@ + + + + + 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')); + } +} 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 03/12] Added tax support --- .../UpdateTenantTax/UpdateTenantTaxAction.php | 66 +++++ .../UpdateTenantTaxRequest.php | 21 ++ .../UpdateTenantTaxResponse.php | 9 + .../ManagedTenantTaxGetController.php | 28 ++ .../ManagedTenantTaxUpdateController.php | 35 +++ .../Controllers/TenantTaxGetController.php | 26 ++ .../Controllers/TenantTaxUpdateController.php | 33 +++ app/Domains/Admin/Routes/api.php | 20 +- .../Admin/Views/Partials/TenantTaxInfo.vue | 240 ++++++++++++++++++ app/Domains/Admin/Views/TenantData.vue | 6 + app/Domains/Admin/Views/TenantEdit.vue | 6 + .../CreateEvent/CreateEventCommand.php | 7 + app/Enumerations/TaxExemptionReason.php | 66 +++++ app/Models/Event.php | 11 +- app/Models/Tenant.php | 17 +- app/Resources/EventResource.php | 9 +- ..._150000_add_tax_information_to_tenants.php | 29 +++ tests/Feature/TenantTaxInformationTest.php | 131 ++++++++++ 18 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php create mode 100644 app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxResponse.php create mode 100644 app/Domains/Admin/Controllers/ManagedTenantTaxGetController.php create mode 100644 app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php create mode 100644 app/Domains/Admin/Controllers/TenantTaxGetController.php create mode 100644 app/Domains/Admin/Controllers/TenantTaxUpdateController.php create mode 100644 app/Domains/Admin/Views/Partials/TenantTaxInfo.vue create mode 100644 app/Enumerations/TaxExemptionReason.php create mode 100644 database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php create mode 100644 tests/Feature/TenantTaxInformationTest.php diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php new file mode 100644 index 0000000..bb9fad7 --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxAction.php @@ -0,0 +1,66 @@ +request->taxLiable) { + $this->request->tenant->update([ + 'tax_liable' => true, + 'vat_rate' => $this->clampVatRate($this->request->vatRate), + 'vat_pricing_mode' => $this->request->vatPricingMode->value, + 'tax_exemption_reason' => null, + 'tax_exemption_note' => null, + ]); + + $response->success = true; + $response->message = 'Steuerinformationen wurden gespeichert.'; + + return $response; + } + + $reason = $this->request->exemptionReason; + + if ($reason === null) { + $response->message = 'Bitte einen gültigen Befreiungsgrund auswählen.'; + + return $response; + } + + $note = trim((string)$this->request->exemptionNote); + + if ($reason->requires_note && $note === '') { + $response->message = 'Bitte einen Text für den Befreiungsgrund angeben.'; + + return $response; + } + + $this->request->tenant->update([ + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => VatPricingMode::Inclusive->value, + 'tax_exemption_reason' => $reason->slug, + 'tax_exemption_note' => $reason->requires_note ? $note : null, + ]); + + $response->success = true; + $response->message = 'Steuerinformationen wurden gespeichert.'; + + return $response; + } + + private function clampVatRate(int $vatRate): int + { + return max(0, min(100, $vatRate)); + } +} diff --git a/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php new file mode 100644 index 0000000..0361b7d --- /dev/null +++ b/app/Domains/Admin/Actions/UpdateTenantTax/UpdateTenantTaxRequest.php @@ -0,0 +1,21 @@ +adminTenants->findBySlug($slug); + + return response()->json([ + 'tax_liable' => $tenant->tax_liable, + 'vat_rate' => $tenant->vat_rate, + 'tax_exemption_reason' => $tenant->tax_exemption_reason, + 'tax_exemption_note' => $tenant->tax_exemption_note, + 'vat_pricing_mode' => $tenant->vat_pricing_mode, + 'exemption_reasons' => TaxExemptionReason::options(), + 'pricing_modes' => VatPricingMode::options(), + 'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax', + ]); + } +} diff --git a/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php new file mode 100644 index 0000000..691c939 --- /dev/null +++ b/app/Domains/Admin/Controllers/ManagedTenantTaxUpdateController.php @@ -0,0 +1,35 @@ +adminTenants->findBySlug($slug); + + $action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $tenant, + taxLiable: $request->boolean('tax_liable'), + vatRate: (int)$request->input('vat_rate', 0), + exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(), + exemptionNote: $request->input('tax_exemption_note'), + vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, + )); + + $response = $action->execute(); + + return response()->json([ + 'status' => $response->success ? 'success' : 'error', + 'message' => $response->message, + ]); + } +} diff --git a/app/Domains/Admin/Controllers/TenantTaxGetController.php b/app/Domains/Admin/Controllers/TenantTaxGetController.php new file mode 100644 index 0000000..d8cce22 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxGetController.php @@ -0,0 +1,26 @@ +json([ + 'tax_liable' => $this->tenant->tax_liable, + 'vat_rate' => $this->tenant->vat_rate, + 'tax_exemption_reason' => $this->tenant->tax_exemption_reason, + 'tax_exemption_note' => $this->tenant->tax_exemption_note, + 'vat_pricing_mode' => $this->tenant->vat_pricing_mode, + 'exemption_reasons' => TaxExemptionReason::options(), + 'pricing_modes' => VatPricingMode::options(), + 'saveEndpoint' => '/api/v1/admin/tenant/tax', + ]); + } +} diff --git a/app/Domains/Admin/Controllers/TenantTaxUpdateController.php b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php new file mode 100644 index 0000000..1b2dea9 --- /dev/null +++ b/app/Domains/Admin/Controllers/TenantTaxUpdateController.php @@ -0,0 +1,33 @@ +tenant, + taxLiable: $request->boolean('tax_liable'), + vatRate: (int)$request->input('vat_rate', 0), + exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(), + exemptionNote: $request->input('tax_exemption_note'), + vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive, + )); + + $response = $action->execute(); + + return response()->json([ + 'status' => $response->success ? 'success' : 'error', + 'message' => $response->message, + ]); + } +} diff --git a/app/Domains/Admin/Routes/api.php b/app/Domains/Admin/Routes/api.php index a7a7069..6bf9597 100644 --- a/app/Domains/Admin/Routes/api.php +++ b/app/Domains/Admin/Routes/api.php @@ -1,10 +1,5 @@ Route::post('/impress', TenantImpressUpdateController::class); Route::get('/gdpr', TenantGdprGetController::class); Route::post('/gdpr', TenantGdprUpdateController::class); + Route::get('/tax', TenantTaxGetController::class); + Route::post('/tax', TenantTaxUpdateController::class); }); Route::prefix('api/v1/admin/users')->group(function () { @@ -69,6 +75,8 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])-> Route::post('/impress', ManagedTenantImpressUpdateController::class); Route::get('/gdpr', ManagedTenantGdprGetController::class); Route::post('/gdpr', ManagedTenantGdprUpdateController::class); + Route::get('/tax', ManagedTenantTaxGetController::class); + Route::post('/tax', ManagedTenantTaxUpdateController::class); }); }); }); diff --git a/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue b/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue new file mode 100644 index 0000000..0ab4f77 --- /dev/null +++ b/app/Domains/Admin/Views/Partials/TenantTaxInfo.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/app/Domains/Admin/Views/TenantData.vue b/app/Domains/Admin/Views/TenantData.vue index 145088f..24b81cb 100644 --- a/app/Domains/Admin/Views/TenantData.vue +++ b/app/Domains/Admin/Views/TenantData.vue @@ -7,6 +7,7 @@ import TenantPayment from "./Partials/TenantPayment.vue"; import TenantImpress from "./Partials/TenantImpress.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue"; import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue"; +import TenantTaxInfo from "./Partials/TenantTaxInfo.vue"; const props = defineProps({ tenant: Object, @@ -28,6 +29,11 @@ const tabs = [ component: TenantPaymentMethods, endpoint: '/api/v1/admin/tenant/payment-methods', }, + { + title: 'Steuerinformationen', + component: TenantTaxInfo, + endpoint: '/api/v1/admin/tenant/tax', + }, { title: 'Impressum', component: TenantImpress, diff --git a/app/Domains/Admin/Views/TenantEdit.vue b/app/Domains/Admin/Views/TenantEdit.vue index 36e4a2a..579b636 100644 --- a/app/Domains/Admin/Views/TenantEdit.vue +++ b/app/Domains/Admin/Views/TenantEdit.vue @@ -7,6 +7,7 @@ import TenantContact from "./Partials/TenantContact.vue"; import TenantPayment from "./Partials/TenantPayment.vue"; import TenantImpress from "./Partials/TenantImpress.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue"; +import TenantTaxInfo from "./Partials/TenantTaxInfo.vue"; const props = defineProps({ tenant: Object, @@ -30,6 +31,11 @@ const tabs = [ component: TenantPayment, endpoint: '/api/v1/admin/tenants/' + slug + '/payment', }, + { + title: 'Steuerinformationen', + component: TenantTaxInfo, + endpoint: '/api/v1/admin/tenants/' + slug + '/tax', + }, { title: 'Impressum', component: TenantImpress, diff --git a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php index 72302ab..7a1ccef 100644 --- a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php +++ b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php @@ -47,6 +47,13 @@ class CreateEventCommand { 'total_max_amount' => 0, 'support_per_person' => 0, 'support_flat' => 0, + // Umsatzsteuerliche Grundlage bei Anlage einfrieren (unveränderlich), damit Preisermittlung und + // spätere Rechnungen von späteren Tenant-Änderungen unberührt bleiben. + 'tax_liable' => app('tenant')->tax_liable, + 'vat_rate' => app('tenant')->vat_rate, + 'vat_pricing_mode' => app('tenant')->vat_pricing_mode, + 'tax_exemption_reason' => app('tenant')->tax_exemption_reason, + 'tax_exemption_note' => app('tenant')->tax_exemption_note, ]); if ($event !== null) { diff --git a/app/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php new file mode 100644 index 0000000..a4af04e --- /dev/null +++ b/app/Enumerations/TaxExemptionReason.php @@ -0,0 +1,66 @@ + 'boolean', + ]; + + /** + * Pflichthinweis für die Rechnung. Bei einem individuellen Grund (requires_note) wird der hinterlegte + * Freitext des Tenants verwendet. + */ + public function invoiceText(?string $customNote = null): string + { + return $this->requires_note ? trim((string)$customNote) : $this->invoice_text; + } + + /** + * Optionen für das Frontend inkl. Vorschau-Text. Bei einem Freitext-Grund ist `text` leer, da der Text + * erst aus dem Freitext des Tenants entsteht. + * + * @return array + */ + public static function options(): array + { + return self::all() + ->map(static fn(self $reason): array => [ + 'value' => $reason->slug, + 'label' => $reason->name, + 'text' => $reason->requires_note ? '' : $reason->invoice_text, + ]) + ->all(); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 20c26bd..ffce8ee 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -8,14 +8,11 @@ use App\Enumerations\EatingHabit; use App\Enumerations\ParticipationFeeType; use App\RelationModels\EventParticipationFee; use App\RelationModels\EventPaymentMethods; -use App\Resources\EventResource; -use App\Scopes\CommonModel; use App\Scopes\InstancedModel; use DateTime; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Http\Resources\Json\JsonResource; /** * @property string $tenant @@ -85,6 +82,11 @@ class Event extends InstancedModel 'support_flat', 'alcoholics_age', 'archived', + 'tax_liable', + 'vat_rate', + 'vat_pricing_mode', + 'tax_exemption_reason', + 'tax_exemption_note', ]; @@ -109,6 +111,9 @@ class Event extends InstancedModel 'total_max_amount' => AmountCast::class, 'sibling_reduction' => 'boolean', + + 'tax_liable' => 'boolean', + 'vat_rate' => 'integer', ]; public function tenant(): BelongsTo diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1efd913..3ef844d 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -21,6 +21,11 @@ use App\Scopes\CommonModel; * @property string $url_participation_rules * @property boolean $events_allowed * @property boolean $has_active_instance + * @property boolean $tax_liable + * @property int $vat_rate + * @property string|null $tax_exemption_reason + * @property string|null $tax_exemption_note + * @property string $vat_pricing_mode */ class Tenant extends CommonModel { @@ -46,6 +51,16 @@ class Tenant extends CommonModel 'impress_text', 'url_participation_rules', 'is_active_local_group', - 'has_active_instance' + 'has_active_instance', + 'tax_liable', + 'vat_rate', + 'tax_exemption_reason', + 'tax_exemption_note', + 'vat_pricing_mode', + ]; + + protected $casts = [ + 'tax_liable' => 'boolean', + 'vat_rate' => 'integer', ]; } diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index 3055e94..8da535c 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -4,6 +4,7 @@ namespace App\Resources; use App\Enumerations\ParticipationFeeType; use App\Enumerations\ParticipationType; +use App\Enumerations\VatPricingMode; use App\Models\Event; use App\ValueObjects\Amount; use DateTime; @@ -342,7 +343,13 @@ class EventResource extends JsonResource{ $basicFee = $basicFee->multiply(0.5); } - return $basicFee; + // Bei bestehender USt-Pflicht und Netto-Preismodus die USt aufschlagen; gespeichert wird immer der + // finale Brutto-Betrag. Bei "inklusive" oder ohne Steuerpflicht bleibt der Beitrag unverändert. + if ($this->event->tax_liable && $this->event->vat_pricing_mode === VatPricingMode::AddOn->value) { + $basicFee = $basicFee->multiply(1 + $this->event->vat_rate / 100); + } + + return new Amount(round($basicFee->getAmount(), 2), 'Euro'); } } diff --git a/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php b/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php new file mode 100644 index 0000000..d72fa77 --- /dev/null +++ b/database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php @@ -0,0 +1,29 @@ +boolean('tax_liable')->default(false); + $table->unsignedTinyInteger('vat_rate')->default(0); + $table->string('tax_exemption_reason')->nullable(); + $table->text('tax_exemption_note')->nullable(); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn([ + 'tax_liable', + 'vat_rate', + 'tax_exemption_reason', + 'tax_exemption_note', + ]); + }); + } +}; diff --git a/tests/Feature/TenantTaxInformationTest.php b/tests/Feature/TenantTaxInformationTest.php new file mode 100644 index 0000000..abced21 --- /dev/null +++ b/tests/Feature/TenantTaxInformationTest.php @@ -0,0 +1,131 @@ +tenant = Tenant::create([ + 'slug' => 'tv', + 'name' => 'Test', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => 'test.local', + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'is_active_local_group' => true, + 'has_active_instance' => true, + ]); + + app()->instance('tenant', $this->tenant); + } + + private function runAction(bool $taxLiable, int $vatRate, ?string $reason, ?string $note = null, string $mode = 'inclusive'): object + { + return new UpdateTenantTaxAction(new UpdateTenantTaxRequest( + tenant: $this->tenant, + taxLiable: $taxLiable, + vatRate: $vatRate, + exemptionReason: $reason ? TaxExemptionReason::where('slug', $reason)->first() : null, + exemptionNote: $note, + vatPricingMode: VatPricingMode::from($mode), + ))->execute(); + } + + public function test_taxable_sets_rate_and_clears_exemption(): void + { + $this->tenant->update(['tax_exemption_reason' => 'small_business', 'tax_exemption_note' => 'alt']); + + $response = $this->runAction(taxLiable: true, vatRate: 7, reason: null); + + $this->assertTrue($response->success); + + $fresh = $this->tenant->fresh(); + $this->assertTrue($fresh->tax_liable); + $this->assertSame(7, $fresh->vat_rate); + $this->assertNull($fresh->tax_exemption_reason); + $this->assertNull($fresh->tax_exemption_note); + } + + public function test_exemption_reason_persists_and_clears_rate(): void + { + $response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'youth_welfare'); + + $this->assertTrue($response->success); + + $fresh = $this->tenant->fresh(); + $this->assertFalse($fresh->tax_liable); + $this->assertSame(0, $fresh->vat_rate); + $this->assertSame('youth_welfare', $fresh->tax_exemption_reason); + $this->assertNull($fresh->tax_exemption_note); + } + + public function test_invalid_exemption_reason_fails(): void + { + $response = $this->runAction(taxLiable: false, vatRate: 0, reason: 'not_a_reason'); + + $this->assertFalse($response->success); + // Unbekannter Slug wird nicht aufgelöst (null) → kein Speichern, Wert bleibt unverändert. + $this->assertNull($this->tenant->fresh()->tax_exemption_reason); + } + + public function test_custom_reason_requires_note(): void + { + $missing = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: ' '); + $this->assertFalse($missing->success); + + $ok = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: 'Individueller Grund'); + $this->assertTrue($ok->success); + $this->assertSame('Individueller Grund', $this->tenant->fresh()->tax_exemption_note); + } + + public function test_vat_rate_is_clamped(): void + { + $this->runAction(taxLiable: true, vatRate: 250, reason: null); + $this->assertSame(100, $this->tenant->fresh()->vat_rate); + } + + public function test_pricing_mode_persists_when_liable(): void + { + $this->runAction(taxLiable: true, vatRate: 19, reason: null, mode: 'add_on'); + $this->assertSame('add_on', $this->tenant->fresh()->vat_pricing_mode); + } + + public function test_pricing_mode_resets_to_inclusive_when_exempt(): void + { + $this->runAction(taxLiable: false, vatRate: 0, reason: 'small_business', mode: 'add_on'); + $this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode); + } + + public function test_invoice_text_per_reason(): void + { + $this->assertSame( + 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + TaxExemptionReason::where('slug', 'small_business')->first()->invoiceText(), + ); + $this->assertSame( + 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', + TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(), + ); + // Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises. + $this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text')); + } +} 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 @@ + + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 3d5e9ab..70258ed 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue @@ -6,6 +6,7 @@ import StepPersonalData from './steps/StepPersonalData.vue' import StepRegistrationMode from './steps/StepRegistrationMode.vue' import StepArrival from './steps/StepArrival.vue' import StepAddons from './steps/StepAddons.vue' +import StepParticipationOptions from './steps/StepParticipationOptions.vue' import StepPhotoPermissions from './steps/StepPhotoPermissions.vue' import StepAllergies from './steps/StepAllergies.vue' import StepPaymentMethod from './steps/StepPaymentMethod.vue' @@ -33,10 +34,11 @@ const steps = [ { step: 4, label: 'An-/Abreise' }, { step: 5, label: 'Teilnahmegruppe' }, { step: 6, label: 'Zusatzoptionen' }, - { step: 7, label: 'Fotoerlaubnis' }, - { step: 8, label: 'Allergien' }, - {step: 9, label: 'Zahlungsart'}, - {step: 10, label: 'Zusammenfassung'}, + {step: 7, label: 'Teilnahmeoptionen'}, + {step: 8, label: 'Fotoerlaubnis'}, + {step: 9, label: 'Allergien'}, + {step: 10, label: 'Zahlungsart'}, + {step: 11, label: 'Zusammenfassung'}, ] @@ -93,12 +95,16 @@ const steps = [ - - - + + + 0 + } + + if (step === STEP_PARTICIPATION_OPTIONS) { + return (event.participationOptions?.length ?? 0) > 0 + } + + return true +} + +// Nächster sichtbarer Schritt nach `step` (überspringt ausgeblendete optionale Schritte). +export function nextVisibleFrom(event, step) { + let candidate = step + 1 + while (candidate < STEP_SUMMARY && !stepVisible(event, candidate)) { + candidate++ + } + return candidate +} + +// Vorheriger sichtbarer Schritt vor `step`. +export function prevVisibleFrom(event, step) { + let candidate = step - 1 + while (candidate > STEP_AGE && !stepVisible(event, candidate)) { + candidate-- + } + return candidate +} diff --git a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js index 5485f66..7a0a709 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js +++ b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js @@ -1,5 +1,6 @@ import {reactive, ref} from 'vue' import axios from 'axios' +import {STEP_PAYMENT, STEP_SUMMARY} from './stepFlow.js' export function useSignupForm(event, participantData) { const currentStep = ref(1) @@ -16,6 +17,8 @@ export function useSignupForm(event, participantData) { eatingHabit: 'EATING_HABIT_VEGAN', paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null, paymentOptions: {}, + // Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value } + participationOptions: {}, userId: participantData.id, eventId: event.id, vorname: participantData.firstname ?? '', @@ -79,10 +82,6 @@ export function useSignupForm(event, participantData) { } } - // Steps: ... 8 Allergien, 9 Zahlungsart, 10 Zusammenfassung. - const STEP_PAYMENT = 9 - const STEP_SUMMARY = 10 - const goToStep = async (step) => { if (step === STEP_PAYMENT) { // Betrag ermitteln: Bei 0 € wird die Zahlungsart-Auswahl übersprungen. diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue index ad1b60c..aab4f94 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue @@ -1,6 +1,10 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue index a9be072..fa53956 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue @@ -43,8 +43,8 @@ const emit = defineEmits(['next', 'back']) - - + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue new file mode 100644 index 0000000..b4d7499 --- /dev/null +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue index f850594..acc8442 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue @@ -78,8 +78,8 @@ const canProceed = computed(() => {
- - +
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue index 43e77e6..db42926 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue @@ -1,16 +1,15 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue index ca46946..d611ec8 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue @@ -1,5 +1,6 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue index 89eeb5f..9173a87 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue @@ -32,8 +32,17 @@ const paymentConfirmationText = computed(() => { return template.replaceAll('{amount}', props.summaryAmount ?? '') }) -// Zurück führt zur Zahlungsart (9), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (8). -const backTarget = computed(() => props.summaryAmountValue > 0 ? 9 : 8) +// Zurück führt zur Zahlungsart (10), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (9). +const backTarget = computed(() => props.summaryAmountValue > 0 ? 10 : 9) + +// Gewählte Teilnahmeoptionen für die Zusammenfassung (label statt value anzeigen). +const selectedParticipationOptions = computed(() => + (props.event.participationOptions ?? []).map(question => { + const value = props.formData.participationOptions[question.key] + const option = (question.options ?? []).find(o => o.value === value) + return {label: question.label, value: option?.label ?? ''} + }).filter(entry => entry.value !== '') +) const canSubmit = computed(() => props.formData.summary_information_correct @@ -115,6 +124,11 @@ function eatingHabit() { {{ participationGroup() }} + + {{ option.label }}: + {{ option.value }} + + Foto-Erlaubnis: diff --git a/app/Models/Event.php b/app/Models/Event.php index ffce8ee..6a7eb7e 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -87,6 +87,7 @@ class Event extends InstancedModel 'vat_pricing_mode', 'tax_exemption_reason', 'tax_exemption_note', + 'participation_options', ]; @@ -114,6 +115,8 @@ class Event extends InstancedModel 'tax_liable' => 'boolean', 'vat_rate' => 'integer', + + 'participation_options' => 'array', ]; public function tenant(): BelongsTo diff --git a/app/Models/EventParticipant.php b/app/Models/EventParticipant.php index 23622a7..b61aca1 100644 --- a/app/Models/EventParticipant.php +++ b/app/Models/EventParticipant.php @@ -14,6 +14,7 @@ use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\EventPaymentModule; use App\EventPaymentModules\EventPaymentModuleRegistry; use App\Scopes\InstancedModel; +use Illuminate\Database\Eloquent\Relations\HasMany; class EventParticipant extends InstancedModel @@ -103,6 +104,12 @@ class EventParticipant extends InstancedModel return $this->belongsTo(Event::class); } + /** Gewählte Teilnahmeoptionen (event-spezifische Pflicht-Auswahl) dieser Anmeldung. */ + public function selectedOptions(): HasMany + { + return $this->hasMany(EventParticipantOption::class, 'event_participant_id'); + } + public function user() { return $this->belongsTo(User::class); diff --git a/app/Models/EventParticipantOption.php b/app/Models/EventParticipantOption.php new file mode 100644 index 0000000..726d76d --- /dev/null +++ b/app/Models/EventParticipantOption.php @@ -0,0 +1,31 @@ +belongsTo(Event::class); + } + + public function participant(): BelongsTo + { + return $this->belongsTo(EventParticipant::class, 'event_participant_id'); + } +} diff --git a/app/Resources/EventParticipantOptionResource.php b/app/Resources/EventParticipantOptionResource.php new file mode 100644 index 0000000..3a9d995 --- /dev/null +++ b/app/Resources/EventParticipantOptionResource.php @@ -0,0 +1,24 @@ + $this->resource->question_key, + 'questionLabel' => $this->resource->question_label, + 'value' => $this->resource->value, + 'valueLabel' => $this->resource->value_label, + ]; + } +} diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php index c5225a7..666e043 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -96,6 +96,8 @@ class EventParticipantResource extends JsonResource 'eventName' => $this->resource->event()->first()->name, 'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'), 'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'), + 'participationOptions' => $this->resource->selectedOptions()->get()->map( + fn($option) => new EventParticipantOptionResource($option)->toArray($request))->toArray(), ] ); } diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index 8da535c..aa59d21 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -138,6 +138,9 @@ class EventResource extends JsonResource{ $returnArray['paymentMethods'] = $this->event->paymentMethods()->get()->map( fn($paymentMethod) => new AvailablePaymentMethodResource($paymentMethod)->toArray($request))->toArray(); + // Veranstaltungsspezifische Pflicht-Auswahlfragen (Teilnahmeoptionen); leeres Array => Schritt entfällt. + $returnArray['participationOptions'] = $this->event->participation_options ?? []; + $returnArray['participationTypes'] = []; diff --git a/database/migrations/2026_08_12_140010_add_participation_options_to_events.php b/database/migrations/2026_08_12_140010_add_participation_options_to_events.php new file mode 100644 index 0000000..b07e30b --- /dev/null +++ b/database/migrations/2026_08_12_140010_add_participation_options_to_events.php @@ -0,0 +1,23 @@ +json('participation_options')->nullable(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn('participation_options'); + }); + } +}; diff --git a/database/migrations/2026_08_12_140020_create_event_participant_options.php b/database/migrations/2026_08_12_140020_create_event_participant_options.php new file mode 100644 index 0000000..b16e2f7 --- /dev/null +++ b/database/migrations/2026_08_12_140020_create_event_participant_options.php @@ -0,0 +1,35 @@ +id(); + $table->string('tenant'); + + $table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + + $table->string('question_key'); + $table->string('question_label'); + $table->string('value'); + $table->string('value_label'); + + $table->timestamps(); + + $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_participant_options'); + } +}; From e95de52768fdf30b110bdef42cb40b89e244e46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnther?= Date: Wed, 12 Aug 2026 15:54:00 +0200 Subject: [PATCH 07/12] Implemented additional event information --- .../SetParticipationOptionsCommand.php | 12 ++- .../UpdateParticipantCommand.php | 52 +++++++++++++ .../UpdateParticipantRequest.php | 2 + .../Event/Controllers/DetailsController.php | 3 + .../MailCompose/ByGroupController.php | 4 + .../ParticipantUpdateController.php | 1 + app/Domains/Event/Views/Details.vue | 8 +- .../Event/Views/Partials/ParticipantData.vue | 40 +++++++++- .../Views/Partials/ParticipantsByOption.vue | 67 ++++++++++++++++ .../Event/Views/Partials/ParticipantsList.vue | 8 +- .../Views/Partials/ParticipationOptions.vue | 47 ++++++++--- .../steps/StepParticipationOptions.vue | 16 +++- .../Partials/SignUpForm/steps/StepSummary.vue | 2 +- .../EventParticipantRepository.php | 78 +++++++++++++++++++ app/Views/Components/Modal.vue | 4 +- app/Views/Components/TabbedPage.vue | 5 +- 16 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 app/Domains/Event/Views/Partials/ParticipantsByOption.vue diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php index 8e7696d..e3276cb 100644 --- a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php +++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php @@ -27,7 +27,7 @@ class SetParticipationOptionsCommand * Geltungsbereichs eindeutig, damit Antworten robust referenzieren können. * * @param array> $questions - * @return array}> + * @return array}> */ private function normalize(array $questions): array { @@ -35,21 +35,25 @@ class SetParticipationOptionsCommand $usedKeys = []; foreach ($questions as $question) { - $label = trim((string)($question['label'] ?? '')); - if ($label === '') { + // Titel ist Pflicht (Überschrift/Feldbeschriftung); die Frage ist optionaler Erklärtext. + $title = trim((string)($question['title'] ?? '')); + if ($title === '') { continue; } + $label = trim((string)($question['label'] ?? '')); + $options = $this->normalizeOptions($question['options'] ?? []); if ($options === []) { continue; } - $key = $this->uniqueSlug((string)($question['key'] ?? ''), $label, $usedKeys); + $key = $this->uniqueSlug((string)($question['key'] ?? ''), $title, $usedKeys); $usedKeys[] = $key; $normalized[] = [ 'key' => $key, + 'title' => $title, 'label' => $label, 'options' => $options, ]; diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php index c333fc0..91883e8 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php @@ -72,11 +72,63 @@ class UpdateParticipantCommand { $p->save(); + + if ($this->request->participationOptions !== null) { + $this->syncParticipationOptions($p); + } + $this->response->success = true; $this->response->participant = $p; return $this->response; } + /** + * Ersetzt die zugeordneten Teilnahmeoptionen anhand der übergebenen Antworten. Nachträgliche Zuordnung im + * Back-Office ist lückenlos möglich: nur gegen die Event-Definition gültige Antworten werden gespeichert, + * leere/entfallene Antworten werden abgeräumt. Frage-/Options-Label werden als Snapshot mitgeschrieben. + */ + private function syncParticipationOptions(EventParticipant $participant): void + { + $participant->selectedOptions()->delete(); + + $answers = $this->request->participationOptions ?? []; + + foreach ($participant->event?->participation_options ?? [] as $question) { + $key = $question['key'] ?? null; + if ($key === null) { + continue; + } + + $value = $answers[$key] ?? null; + if ($value === null || $value === '') { + continue; + } + + $option = null; + foreach ($question['options'] ?? [] as $candidate) { + if (($candidate['value'] ?? null) === $value) { + $option = $candidate; + break; + } + } + + if ($option === null) { + continue; + } + + $label = trim((string)($question['label'] ?? '')); + + $participant->selectedOptions()->create([ + 'tenant' => $participant->tenant, + 'event_id' => $participant->event_id, + 'question_key' => $key, + 'question_label' => $label !== '' ? $label : ($question['title'] ?? $key), + 'value' => $value, + 'value_label' => $option['label'] ?? $value, + ]); + } + } + private function handleAmountChanges(EventParticipant $participant) { $this->response->amountPaid = $participant->amount_paid; $this->response->amountExpected = $participant->amount; diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php index 443695a..8d6166a 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php @@ -36,5 +36,7 @@ class UpdateParticipantRequest { public string $amountPaid, public string $amountExpected, public string $cocStatus, + /** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. null = unverändert lassen. */ + public ?array $participationOptions = null, ) {} } diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index 2bb71d2..bb204ca 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -208,6 +208,9 @@ class DetailsController extends CommonController { case 'by-participation-group': $participants = $this->eventParticipants->groupByParticipationType($event, $request); break; + case 'by-participation-option': + $participants = $this->eventParticipants->groupByParticipationOption($event, $request); + break; case 'signed-off': $participants = $this->eventParticipants->getSignedOffParticipants($event, $request); break; diff --git a/app/Domains/Event/Controllers/MailCompose/ByGroupController.php b/app/Domains/Event/Controllers/MailCompose/ByGroupController.php index f265b25..9865ce5 100644 --- a/app/Domains/Event/Controllers/MailCompose/ByGroupController.php +++ b/app/Domains/Event/Controllers/MailCompose/ByGroupController.php @@ -19,6 +19,10 @@ class ByGroupController extends CommonController $participants = $this->eventParticipants->groupByParticipationType($event, $request, $request->input('groupName')); $recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]); break; + case 'by-participation-option': + $participants = $this->eventParticipants->groupByParticipationOption($event, $request, $request->input('groupName')); + $recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')] ?? []); + break; case 'signed-off': $participants = $this->eventParticipants->getSignedOffParticipants($event, $request, $request->input('groupName')); $recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]); diff --git a/app/Domains/Event/Controllers/ParticipantUpdateController.php b/app/Domains/Event/Controllers/ParticipantUpdateController.php index f4d1dbc..4d07f40 100644 --- a/app/Domains/Event/Controllers/ParticipantUpdateController.php +++ b/app/Domains/Event/Controllers/ParticipantUpdateController.php @@ -44,6 +44,7 @@ class ParticipantUpdateController extends CommonController { amountPaid: $request->input('amountPaid'), amountExpected: $request->input('amountExpected'), cocStatus: $request->input('cocStatus'), + participationOptions: $request->has('participationOptions') ? (array)$request->input('participationOptions') : null, ); $command = new UpdateParticipantCommand($updateRequest); diff --git a/app/Domains/Event/Views/Details.vue b/app/Domains/Event/Views/Details.vue index 8fdf945..fc57a06 100644 --- a/app/Domains/Event/Views/Details.vue +++ b/app/Domains/Event/Views/Details.vue @@ -1,9 +1,10 @@ + + + + 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() {
- +
+ + + + + +
@@ -127,10 +139,25 @@ async function save() { .question-head { display: flex; - align-items: center; + align-items: flex-start; gap: 12px; } +.question-fields { + flex: 1; +} + +.field-label { + display: block; + font-size: 0.78rem; + color: #6b7280; + margin: 6px 0 2px; +} + +.question-fields .field-label:first-child { + margin-top: 0; +} + .options { margin-top: 12px; padding-left: 12px; diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue index b4d7499..094eecd 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue @@ -28,7 +28,10 @@ const next = () => emit('next', nextVisibleFrom(props.event, STEP_PARTICIPATION_ - + + + + + + - - - - -
{{ question.label }}: + {{ question.title || question.label }}: + {{ question.label }} + - - - - -
{{ question.title || question.label }} - {{ answerLabelFor(question.key) }} - -
+ + +
+
-
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 70258ed..80f4165 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue @@ -107,6 +107,7 @@ const steps = [ v-if="currentStep === 11" :formData="formData" :event="event" + :selectedAddons="selectedAddons" :summaryAmount="summaryAmount" :summaryAmountValue="summaryAmountValue" :summaryLoading="summaryLoading" diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue index aab4f94..291db83 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue @@ -29,13 +29,21 @@ const next = () => emit('next', nextVisibleFrom(props.event, STEP_ADDONS))

Zusatzoptionen

-
+
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue index 64c08ed..e5c985e 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue @@ -7,6 +7,7 @@ const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION' const props = defineProps({ formData: Object, event: Object, + selectedAddons: {type: Object, default: () => ({})}, summaryAmount: String, summaryAmountValue: {type: Number, default: 0}, summaryLoading: Boolean, @@ -35,6 +36,16 @@ const paymentConfirmationText = computed(() => { // Zurück führt zur Zahlungsart (10), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (9). const backTarget = computed(() => props.summaryAmountValue > 0 ? 10 : 9) +// Gewählte Zusätze (Event-Addons) für die Zusammenfassung mit Preisanzeige. +const selectedAddonList = computed(() => + (props.event.addons ?? []) + .filter(addon => props.selectedAddons[addon.key]) + .map(addon => ({ + title: addon.title, + price: addon.free ? 'Kostenfrei' : (addon.priceReadable + (addon.flat ? '' : ' / Tag')), + })) +) + // Gewählte Teilnahmeoptionen für die Zusammenfassung (label statt value anzeigen). const selectedParticipationOptions = computed(() => (props.event.participationOptions ?? []).map(question => { @@ -129,6 +140,11 @@ function eatingHabit() {
{{ option.value }}
{{ addon.title }}:{{ addon.price }}
Foto-Erlaubnis: diff --git a/app/Models/Event.php b/app/Models/Event.php index 6a7eb7e..049f2b4 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -88,6 +88,7 @@ class Event extends InstancedModel 'tax_exemption_reason', 'tax_exemption_note', 'participation_options', + 'addons', ]; @@ -117,6 +118,7 @@ class Event extends InstancedModel 'vat_rate' => 'integer', 'participation_options' => 'array', + 'addons' => 'array', ]; public function tenant(): BelongsTo diff --git a/app/Models/EventParticipant.php b/app/Models/EventParticipant.php index b61aca1..425aca8 100644 --- a/app/Models/EventParticipant.php +++ b/app/Models/EventParticipant.php @@ -110,6 +110,12 @@ class EventParticipant extends InstancedModel return $this->hasMany(EventParticipantOption::class, 'event_participant_id'); } + /** Gebuchte Zusätze (Event-Addons) dieser Anmeldung. */ + public function selectedAddons(): HasMany + { + return $this->hasMany(EventParticipantAddon::class, 'event_participant_id'); + } + public function user() { return $this->belongsTo(User::class); diff --git a/app/Models/EventParticipantAddon.php b/app/Models/EventParticipantAddon.php new file mode 100644 index 0000000..574cf91 --- /dev/null +++ b/app/Models/EventParticipantAddon.php @@ -0,0 +1,41 @@ + 'boolean', + 'days' => 'integer', + 'price' => AmountCast::class, + 'amount' => AmountCast::class, + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function participant(): BelongsTo + { + return $this->belongsTo(EventParticipant::class, 'event_participant_id'); + } +} diff --git a/app/Resources/EventParticipantAddonResource.php b/app/Resources/EventParticipantAddonResource.php new file mode 100644 index 0000000..41663f9 --- /dev/null +++ b/app/Resources/EventParticipantAddonResource.php @@ -0,0 +1,26 @@ + $this->resource->addon_key, + 'title' => $this->resource->title, + 'flat' => $this->resource->flat, + 'amount' => $this->resource->amount?->getAmount() ?? 0, + 'amountReadable' => $this->resource->amount?->toString() ?? '0,00 Euro', + 'free' => ($this->resource->amount?->getAmount() ?? 0) <= 0, + ]; + } +} diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php index 666e043..0a8c185 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -98,6 +98,8 @@ class EventParticipantResource extends JsonResource 'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'), 'participationOptions' => $this->resource->selectedOptions()->get()->map( fn($option) => new EventParticipantOptionResource($option)->toArray($request))->toArray(), + 'selectedAddons' => $this->resource->selectedAddons()->get()->map( + fn($addon) => new EventParticipantAddonResource($addon)->toArray($request))->toArray(), ] ); } diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index aa59d21..81324ab 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -141,6 +141,20 @@ class EventResource extends JsonResource{ // Veranstaltungsspezifische Pflicht-Auswahlfragen (Teilnahmeoptionen); leeres Array => Schritt entfällt. $returnArray['participationOptions'] = $this->event->participation_options ?? []; + // Buchbare Zusätze (Event-Addons); leeres Array => Schritt entfällt. Preis 0 => "Kostenfrei". + $returnArray['addons'] = collect($this->event->addons ?? [])->map(function ($addon) { + $price = (float)($addon['price'] ?? 0); + return [ + 'key' => $addon['key'] ?? '', + 'title' => $addon['title'] ?? '', + 'description' => $addon['description'] ?? '', + 'price' => $price, + 'priceReadable' => new Amount($price, 'Euro')->toString(), + 'free' => $price <= 0, + 'flat' => (bool)($addon['flat'] ?? false), + ]; + })->toArray(); + $returnArray['participationTypes'] = []; @@ -354,6 +368,54 @@ class EventResource extends JsonResource{ return new Amount(round($basicFee->getAmount(), 2), 'Euro'); } + + /** + * Löst die gewählten Zusätze (Event-Addons) gegen die Event-Definition auf und berechnet je Position den + * Betrag: Pauschale => Preis, sonst Preis × Teilnahmetage. Bei USt-Pflicht + Netto-Preismodus (`add_on`) + * wird die USt aufgeschlagen (analog Beitrag); bei „inclusive"/keiner Pflicht bleibt der Preis unverändert. + * Kein Early-Bird-Aufschlag. + * + * @param array $selectedKeys + * @return array{items: array, total: Amount} + */ + public function resolveAddons(array $selectedKeys, DateTime $arrival, DateTime $departure): array + { + $days = $arrival->diff($departure)->days + 1; + $addsVat = $this->event->tax_liable && $this->event->vat_pricing_mode === VatPricingMode::AddOn->value; + + $items = []; + $total = new Amount(0, 'Euro'); + + foreach ($this->event->addons ?? [] as $addon) { + $key = $addon['key'] ?? null; + if ($key === null || !in_array($key, $selectedKeys, true)) { + continue; + } + + $price = (float)($addon['price'] ?? 0); + $flat = (bool)($addon['flat'] ?? false); + $usedDays = $flat ? 1 : $days; + + $amount = $flat ? $price : $price * $days; + if ($addsVat) { + $amount *= 1 + $this->event->vat_rate / 100; + } + $amount = round($amount, 2); + + $items[] = [ + 'key' => $key, + 'title' => $addon['title'] ?? $key, + 'price' => $price, + 'flat' => $flat, + 'days' => $usedDays, + 'amount' => $amount, + ]; + + $total->addAmount(new Amount($amount, 'Euro')); + } + + return ['items' => $items, 'total' => $total]; + } } diff --git a/database/migrations/2026_08_12_150010_add_addons_to_events.php b/database/migrations/2026_08_12_150010_add_addons_to_events.php new file mode 100644 index 0000000..0131248 --- /dev/null +++ b/database/migrations/2026_08_12_150010_add_addons_to_events.php @@ -0,0 +1,23 @@ +json('addons')->nullable(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn('addons'); + }); + } +}; diff --git a/database/migrations/2026_08_12_150020_create_event_participant_addons.php b/database/migrations/2026_08_12_150020_create_event_participant_addons.php new file mode 100644 index 0000000..af63958 --- /dev/null +++ b/database/migrations/2026_08_12_150020_create_event_participant_addons.php @@ -0,0 +1,36 @@ +id(); + $table->string('tenant'); + + $table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + + $table->string('addon_key'); + $table->string('title'); + $table->float('price')->default(0); + $table->boolean('flat')->default(false); + $table->integer('days')->default(0); + $table->float('amount')->default(0); + + $table->timestamps(); + + $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_participant_addons'); + } +}; From 1cc84716a15ffc61a888fdb4dee6ba8dbf718c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnther?= Date: Wed, 12 Aug 2026 16:55:44 +0200 Subject: [PATCH 09/12] UI improvements --- .../Event/Controllers/SignupController.php | 24 +++++++++-- .../Views/Partials/SignUpForm/SignupForm.vue | 6 ++- .../SignUpForm/composables/useSignupForm.js | 8 +++- .../Partials/SignUpForm/steps/StepSummary.vue | 41 +++++++++++-------- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/app/Domains/Event/Controllers/SignupController.php b/app/Domains/Event/Controllers/SignupController.php index 7dbfefa..9de5ea7 100644 --- a/app/Domains/Event/Controllers/SignupController.php +++ b/app/Domains/Event/Controllers/SignupController.php @@ -12,6 +12,7 @@ use App\Providers\DoubleCheckEventRegistrationProvider; use App\Providers\InertiaProvider; use App\Resources\UserResource; use App\Scopes\CommonController; +use App\ValueObjects\Amount; use DateTime; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -194,7 +195,7 @@ class SignupController extends CommonController { $arrival = \DateTime::createFromFormat('Y-m-d', $request->input('arrival')); $departure = \DateTime::createFromFormat('Y-m-d', $request->input('departure')); - $amount = $event->calculateAmount( + $baseFee = $event->calculateAmount( $request->input('participationType'), $request->input('beitrag'), $arrival, @@ -205,12 +206,27 @@ class SignupController extends CommonController { // Gewählte Zusätze immer einrechnen -- so enthält der finale Anzeigebetrag (und der Bestätigungstext) // stets die Addons und der Zahlungsschritt wird nur bei Gesamt 0 € übersprungen. $selectedAddonKeys = array_keys(array_filter((array)$request->input('addons', []), fn($v) => (bool)$v)); - $amount->addAmount($event->resolveAddons($selectedAddonKeys, $arrival, $departure)['total']); + $resolution = $event->resolveAddons($selectedAddonKeys, $arrival, $departure); + + // Gesamt = Teilnahmebeitrag + Zusätze; Basisbetrag separat für die Kostenübersicht in der Zusammenfassung. + $total = new Amount($baseFee->getAmount(), 'Euro'); + $total->addAmount($resolution['total']); + + $addonLines = array_map(fn($item) => [ + 'key' => $item['key'], + 'title' => $item['title'], + 'amountValue' => $item['amount'], + 'amount' => new Amount($item['amount'], 'Euro')->toString(), + 'free' => $item['amount'] <= 0, + ], $resolution['items']); // amountValue (numerisch) steuert im Frontend das Überspringen des Zahlungsart-Schritts bei 0 €. return response()->json([ - 'amount' => $amount->toString(), - 'amountValue' => $amount->getAmount(), + 'amount' => $total->toString(), + 'amountValue' => $total->getAmount(), + 'baseAmount' => $baseFee->toString(), + 'baseAmountValue' => $baseFee->getAmount(), + 'addons' => $addonLines, ]); } } diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 80f4165..63f5f4c 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue @@ -24,7 +24,8 @@ const emit = defineEmits(['registrationDone']) const { currentStep, goToStep, formData, selectedAddons, - submit, submitting, submitResult, summaryLoading, summaryAmount, summaryAmountValue + submit, submitting, submitResult, summaryLoading, summaryAmount, summaryAmountValue, + summaryBaseAmount, summaryAddonLines } = useSignupForm(props.event, props.participantData) const steps = [ @@ -107,7 +108,8 @@ const steps = [ v-if="currentStep === 11" :formData="formData" :event="event" - :selectedAddons="selectedAddons" + :summaryBaseAmount="summaryBaseAmount" + :summaryAddonLines="summaryAddonLines" :summaryAmount="summaryAmount" :summaryAmountValue="summaryAmountValue" :summaryLoading="summaryLoading" diff --git a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js index 7a0a709..a68615b 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js +++ b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js @@ -60,6 +60,8 @@ export function useSignupForm(event, participantData) { const summaryAmount = ref('') const summaryAmountValue = ref(0) + const summaryBaseAmount = ref('') + const summaryAddonLines = ref([]) const fetchAmount = async () => { summaryLoading.value = true @@ -77,6 +79,8 @@ export function useSignupForm(event, participantData) { }) summaryAmount.value = res.data.amount summaryAmountValue.value = Number(res.data.amountValue ?? 0) + summaryBaseAmount.value = res.data.baseAmount ?? '' + summaryAddonLines.value = res.data.addons ?? [] } finally { summaryLoading.value = false } @@ -128,6 +132,8 @@ export function useSignupForm(event, participantData) { submitResult, summaryLoading, summaryAmount, - summaryAmountValue + summaryAmountValue, + summaryBaseAmount, + summaryAddonLines } } diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue index e5c985e..ed6f5f3 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue @@ -7,7 +7,8 @@ const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION' const props = defineProps({ formData: Object, event: Object, - selectedAddons: {type: Object, default: () => ({})}, + summaryBaseAmount: {type: String, default: ''}, + summaryAddonLines: {type: Array, default: () => []}, summaryAmount: String, summaryAmountValue: {type: Number, default: 0}, summaryLoading: Boolean, @@ -36,16 +37,6 @@ const paymentConfirmationText = computed(() => { // Zurück führt zur Zahlungsart (10), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (9). const backTarget = computed(() => props.summaryAmountValue > 0 ? 10 : 9) -// Gewählte Zusätze (Event-Addons) für die Zusammenfassung mit Preisanzeige. -const selectedAddonList = computed(() => - (props.event.addons ?? []) - .filter(addon => props.selectedAddons[addon.key]) - .map(addon => ({ - title: addon.title, - price: addon.free ? 'Kostenfrei' : (addon.priceReadable + (addon.flat ? '' : ' / Tag')), - })) -) - // Gewählte Teilnahmeoptionen für die Zusammenfassung (label statt value anzeigen). const selectedParticipationOptions = computed(() => (props.event.participationOptions ?? []).map(question => { @@ -140,11 +131,6 @@ function eatingHabit() { {{ option.value }}
{{ addon.title }}:{{ addon.price }}
Foto-Erlaubnis: @@ -179,6 +165,22 @@ function eatingHabit() {
Abreise:{{ formatDate(formData.departure) }}
+

Kostenübersicht

+ + + + + + + + + + + + + +
Teilnahmebeitrag:{{ summaryBaseAmount }}
{{ addon.title }}:{{ addon.free ? 'Kostenfrei' : addon.amount }}
Gesamtbetrag:{{ summaryAmount }}
+
+ + From cd51eb3f973424c774745bb58d73f91209be8a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnther?= Date: Wed, 12 Aug 2026 17:08:21 +0200 Subject: [PATCH 10/12] Handling of event addons --- .../Event/Controllers/DetailsController.php | 3 + app/Domains/Event/Views/Details.vue | 11 +-- .../Views/Partials/ParticipantsByAddon.vue | 67 +++++++++++++++++++ .../EventParticipantRepository.php | 38 +++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 app/Domains/Event/Views/Partials/ParticipantsByAddon.vue diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index a197c4e..fc3f735 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -224,6 +224,9 @@ class DetailsController extends CommonController { case 'by-participation-option': $participants = $this->eventParticipants->groupByParticipationOption($event, $request); break; + case 'by-addon': + $participants = $this->eventParticipants->groupByAddon($event, $request); + break; case 'signed-off': $participants = $this->eventParticipants->getSignedOffParticipants($event, $request); break; diff --git a/app/Domains/Event/Views/Details.vue b/app/Domains/Event/Views/Details.vue index fc57a06..6476ad8 100644 --- a/app/Domains/Event/Views/Details.vue +++ b/app/Domains/Event/Views/Details.vue @@ -5,6 +5,7 @@ import ShadowedBox from "../../../Views/Components/ShadowedBox.vue"; import TabbedPage from "../../../Views/Components/TabbedPage.vue"; import ParticipantsList from "./Partials/ParticipantsList.vue"; import ParticipantsByOption from "./Partials/ParticipantsByOption.vue"; +import ParticipantsByAddon from "./Partials/ParticipantsByAddon.vue"; import Overview from "./Partials/Overview.vue"; const props = defineProps({ @@ -37,16 +38,16 @@ const tabs = [ component: ParticipantsByOption, endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-participation-option', }, + { + title: 'Teilis mit Zusatzoptionen', + component: ParticipantsByAddon, + endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-addon', + }, { title: 'Abgemeldete Teilis', component: ParticipantsList, endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/signed-off', }, - { - title: 'Zusätze', - component: ParticipantsList, - endpoint: "/api/v1/cost-unit/open/archived-cost-units", - }, ] onMounted(() => { diff --git a/app/Domains/Event/Views/Partials/ParticipantsByAddon.vue b/app/Domains/Event/Views/Partials/ParticipantsByAddon.vue new file mode 100644 index 0000000..7753814 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ParticipantsByAddon.vue @@ -0,0 +1,67 @@ + + + + + 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