From 72e0502bb908ac752c6f38e8c5546ac388275d65 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 1/2] Bugfix on closing cost units --- .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 6e4e6b645cfae6633d5c9df9eed96c5916ee4fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnther?= Date: Wed, 12 Aug 2026 17:18:45 +0200 Subject: [PATCH 2/2] Handling of event addons --- CLAUDE.md | 57 +++++ .../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 + .../SetEventAddons/SetEventAddonsCommand.php | 85 +++++++ .../SetEventAddons/SetEventAddonsRequest.php | 19 ++ .../SetEventAddons/SetEventAddonsResponse.php | 15 ++ .../SetParticipationOptionsCommand.php | 115 +++++++++ .../SetParticipationOptionsRequest.php | 19 ++ .../SetParticipationOptionsResponse.php | 15 ++ .../Event/Actions/SignUp/SignUpCommand.php | 89 +++++++ .../Event/Actions/SignUp/SignUpRequest.php | 4 + .../UpdateParticipantCommand.php | 86 +++++++ .../UpdateParticipantRequest.php | 4 + .../Event/Controllers/DetailsController.php | 32 +++ .../MailCompose/ByGroupController.php | 4 + .../ParticipantUpdateController.php | 2 + .../Event/Controllers/SignupController.php | 41 ++- app/Domains/Event/Routes/api.php | 2 + app/Domains/Event/Views/Details.vue | 19 +- .../Event/Views/Partials/EventAddons.vue | 166 ++++++++++++ app/Domains/Event/Views/Partials/Overview.vue | 38 ++- .../Event/Views/Partials/ParticipantData.vue | 68 ++++- .../Views/Partials/ParticipantsByAddon.vue | 67 +++++ .../Views/Partials/ParticipantsByOption.vue | 67 +++++ .../Event/Views/Partials/ParticipantsList.vue | 8 +- .../Views/Partials/ParticipationOptions.vue | 182 +++++++++++++ .../Views/Partials/SignUpForm/SignupForm.vue | 27 +- .../SignUpForm/composables/stepFlow.js | 45 ++++ .../SignUpForm/composables/useSignupForm.js | 15 +- .../Partials/SignUpForm/steps/StepAddons.vue | 24 +- .../SignUpForm/steps/StepAllergies.vue | 4 +- .../steps/StepParticipationOptions.vue | 62 +++++ .../SignUpForm/steps/StepPaymentMethod.vue | 4 +- .../SignUpForm/steps/StepPhotoPermissions.vue | 11 +- .../SignUpForm/steps/StepRegistrationMode.vue | 6 +- .../Partials/SignUpForm/steps/StepSummary.vue | 43 +++- app/Enumerations/TaxExemptionReason.php | 70 +++++ app/Enumerations/VatPricingMode.php | 38 +++ app/Models/Event.php | 16 +- app/Models/EventParticipant.php | 13 + app/Models/EventParticipantAddon.php | 41 +++ app/Models/EventParticipantOption.php | 31 +++ app/Models/Tenant.php | 17 +- .../EventParticipantRepository.php | 116 +++++++++ .../EventParticipantAddonResource.php | 26 ++ .../EventParticipantOptionResource.php | 24 ++ app/Resources/EventParticipantResource.php | 4 + app/Resources/EventResource.php | 74 +++++- app/Views/Components/Modal.vue | 4 +- app/Views/Components/RichSelectBox.vue | 75 ++++-- app/Views/Components/TabbedPage.vue | 5 +- ..._150000_add_tax_information_to_tenants.php | 29 +++ ...160000_add_vat_pricing_mode_to_tenants.php | 21 ++ ...8_06_160010_add_tax_snapshot_to_events.php | 36 +++ ...06_170000_create_tax_exemption_reasons.php | 23 ++ ..._add_tax_exemption_reason_foreign_keys.php | 48 ++++ ...06_170020_refine_tax_exemption_reasons.php | 81 ++++++ ...fault_tax_exemption_reason_jugendhilfe.php | 20 ++ ...10_add_participation_options_to_events.php | 23 ++ ...40020_create_event_participant_options.php | 35 +++ ...2026_08_12_150010_add_addons_to_events.php | 23 ++ ...150020_create_event_participant_addons.php | 36 +++ tests/Feature/EventVatPricingTest.php | 155 +++++++++++ tests/Feature/TenantTaxInformationTest.php | 141 ++++++++++ 74 files changed, 3072 insertions(+), 95 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/Domains/Event/Actions/SetEventAddons/SetEventAddonsCommand.php create mode 100644 app/Domains/Event/Actions/SetEventAddons/SetEventAddonsRequest.php create mode 100644 app/Domains/Event/Actions/SetEventAddons/SetEventAddonsResponse.php 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/EventAddons.vue create mode 100644 app/Domains/Event/Views/Partials/ParticipantsByAddon.vue create mode 100644 app/Domains/Event/Views/Partials/ParticipantsByOption.vue 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/Enumerations/TaxExemptionReason.php create mode 100644 app/Enumerations/VatPricingMode.php create mode 100644 app/Models/EventParticipantAddon.php create mode 100644 app/Models/EventParticipantOption.php create mode 100644 app/Resources/EventParticipantAddonResource.php create mode 100644 app/Resources/EventParticipantOptionResource.php create mode 100644 database/migrations/2026_08_06_150000_add_tax_information_to_tenants.php create mode 100644 database/migrations/2026_08_06_160000_add_vat_pricing_mode_to_tenants.php create mode 100644 database/migrations/2026_08_06_160010_add_tax_snapshot_to_events.php create mode 100644 database/migrations/2026_08_06_170000_create_tax_exemption_reasons.php create mode 100644 database/migrations/2026_08_06_170010_add_tax_exemption_reason_foreign_keys.php 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 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 create mode 100644 database/migrations/2026_08_12_150010_add_addons_to_events.php create mode 100644 database/migrations/2026_08_12_150020_create_event_participant_addons.php create mode 100644 tests/Feature/EventVatPricingTest.php create mode 100644 tests/Feature/TenantTaxInformationTest.php 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. 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..e02187b --- /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..5d6af6e --- /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..f8b17b8 --- /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/Domains/Event/Actions/SetEventAddons/SetEventAddonsCommand.php b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsCommand.php new file mode 100644 index 0000000..2f41953 --- /dev/null +++ b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsCommand.php @@ -0,0 +1,85 @@ +request->event->addons = $this->normalize($this->request->addons); + $this->request->event->save(); + + $response->success = true; + return $response; + } + + /** + * Normalisiert die rohe Addon-Definition: trimmt Titel/Beschreibung, parst den Preis (≥ 0), erzwingt den + * Titel als Pflichtfeld und generiert stabile Keys (Bestandsschutz für bereits gespeicherte Buchungen). + * + * @param array> $addons + * @return array + */ + private function normalize(array $addons): array + { + $normalized = []; + $usedKeys = []; + + foreach ($addons as $addon) { + $title = trim((string)($addon['title'] ?? '')); + if ($title === '') { + continue; + } + + $price = Amount::fromString((string)($addon['price'] ?? '0'))->getAmount(); + if ($price < 0) { + $price = 0.0; + } + + $key = $this->uniqueSlug((string)($addon['key'] ?? ''), $title, $usedKeys); + $usedKeys[] = $key; + + $normalized[] = [ + 'key' => $key, + 'title' => $title, + 'description' => trim((string)($addon['description'] ?? '')), + 'price' => round($price, 2), + 'flat' => (bool)($addon['flat'] ?? false), + ]; + } + + return $normalized; + } + + /** + * Liefert einen eindeutigen Slug: bevorzugt den bestehenden Wert, sonst aus dem Titel abgeleitet; + * Kollisionen werden durchnummeriert. + * + * @param array $used + */ + private function uniqueSlug(string $existing, string $title, array $used): string + { + $base = $existing !== '' ? $existing : Str::slug($title); + if ($base === '') { + $base = 'addon'; + } + + $candidate = $base; + $suffix = 2; + while (in_array($candidate, $used, true)) { + $candidate = $base . '-' . $suffix; + $suffix++; + } + + return $candidate; + } +} diff --git a/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsRequest.php b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsRequest.php new file mode 100644 index 0000000..d4ede3b --- /dev/null +++ b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsRequest.php @@ -0,0 +1,19 @@ +> Rohe Addon-Definition aus dem Admin-Panel. */ + public array $addons; + + public function __construct(Event $event, array $addons) + { + $this->event = $event; + $this->addons = $addons; + } +} diff --git a/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsResponse.php b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsResponse.php new file mode 100644 index 0000000..b398f84 --- /dev/null +++ b/app/Domains/Event/Actions/SetEventAddons/SetEventAddonsResponse.php @@ -0,0 +1,15 @@ +success = false; + $this->message = null; + } +} diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php new file mode 100644 index 0000000..e3276cb --- /dev/null +++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php @@ -0,0 +1,115 @@ +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) { + // 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'] ?? ''), $title, $usedKeys); + $usedKeys[] = $key; + + $normalized[] = [ + 'key' => $key, + 'title' => $title, + '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..7196acf 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,89 @@ class SignUpCommand { ] ); + $this->persistParticipationOptions($response->participant, $participationOptionRows); + $this->persistAddons($response->participant); + $response->success = true; return $response; } + /** + * Speichert die gewählten Zusätze (Event-Addons) als Snapshot-Zeilen (Titel/Preis/Betrag) für die spätere + * Rechnung. Die Beträge sind bereits in der Controller-Auflösung (`resolveAddons`) berechnet, inkl. USt. + */ + private function persistAddons(\App\Models\EventParticipant $participant): void + { + foreach ($this->request->addonItems as $item) { + $participant->selectedAddons()->create([ + 'tenant' => $this->request->event->tenant, + 'event_id' => $this->request->event->id, + 'addon_key' => $item['key'], + 'title' => $item['title'], + 'price' => $item['price'] ?? 0, + 'flat' => (bool)($item['flat'] ?? false), + 'days' => $item['days'] ?? 0, + 'amount' => $item['amount'] ?? 0, + ]); + } + } + + /** + * 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..cec782f 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php +++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php @@ -46,6 +46,10 @@ class SignUpRequest { public Amount $amount, public ?string $paymentMethod, public array $paymentOptions = [], + /** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */ + public array $participationOptions = [], + /** Aufgelöste Zusätze (Event-Addons): je Eintrag [ key, title, price, flat, days, amount ]. */ + public array $addonItems = [], ) { } } diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php index c333fc0..1f614c8 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php @@ -72,11 +72,97 @@ class UpdateParticipantCommand { $p->save(); + + if ($this->request->participationOptions !== null) { + $this->syncParticipationOptions($p); + } + + if ($this->request->selectedAddons !== null) { + $this->syncAddons($p); + } + $this->response->success = true; $this->response->participant = $p; return $this->response; } + /** + * Ersetzt die zugeordneten Zusätze (Event-Addons) anhand der Auswahl. Bewusst **ohne Preis/Betrag** + * (amount 0): der Teilnahmebeitrag wird in den Details ohnehin manuell gesetzt. Es wird nur der Titel als + * Snapshot gespeichert, damit die Zuordnung sichtbar bleibt. + */ + private function syncAddons(EventParticipant $participant): void + { + $participant->selectedAddons()->delete(); + + $selection = $this->request->selectedAddons ?? []; + + foreach ($participant->event?->addons ?? [] as $addon) { + $key = $addon['key'] ?? null; + if ($key === null || empty($selection[$key])) { + continue; + } + + $participant->selectedAddons()->create([ + 'tenant' => $participant->tenant, + 'event_id' => $participant->event_id, + 'addon_key' => $key, + 'title' => $addon['title'] ?? $key, + 'price' => 0, + 'flat' => (bool)($addon['flat'] ?? false), + 'days' => 0, + 'amount' => 0, + ]); + } + } + + /** + * 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..ccda46d 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php @@ -36,5 +36,9 @@ 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, + /** Gewählte Zusätze: [ addon_key => bool ]. null = unverändert lassen. */ + public ?array $selectedAddons = null, ) {} } diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index b64c281..fc3f735 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -2,8 +2,12 @@ namespace App\Domains\Event\Controllers; +use App\Domains\Event\Actions\SetEventAddons\SetEventAddonsCommand; +use App\Domains\Event\Actions\SetEventAddons\SetEventAddonsRequest; 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 +146,28 @@ 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 updateEventAddons(int $eventId, Request $request): JsonResponse + { + $event = $this->events->getById($eventId); + + $setRequest = new SetEventAddonsRequest($event, (array)$request->input('addons', [])); + $setCommand = new SetEventAddonsCommand($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); @@ -195,6 +221,12 @@ 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 '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/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..50911aa 100644 --- a/app/Domains/Event/Controllers/ParticipantUpdateController.php +++ b/app/Domains/Event/Controllers/ParticipantUpdateController.php @@ -44,6 +44,8 @@ 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, + selectedAddons: $request->has('selectedAddons') ? (array)$request->input('selectedAddons') : null, ); $command = new UpdateParticipantCommand($updateRequest); diff --git a/app/Domains/Event/Controllers/SignupController.php b/app/Domains/Event/Controllers/SignupController.php index 643d0d6..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; @@ -104,6 +105,11 @@ class SignupController extends CommonController { $siblingReduction ); + // Gewählte Zusätze (Event-Addons) auflösen und auf den Gesamtbetrag aufaddieren. + $selectedAddonKeys = array_keys(array_filter((array)$request->input('addons', []), fn($v) => (bool)$v)); + $addonResolution = $eventResource->resolveAddons($selectedAddonKeys, $arrival, $departure); + $amount->addAmount($addonResolution['total']); + $signupRequest = new SignUpRequest( $event, $registrationData['userId'] ?? null, @@ -142,6 +148,8 @@ class SignupController extends CommonController { $amount, $registrationData['paymentMethod'] ?? null, (array)($registrationData['paymentOptions'] ?? []), + (array)($registrationData['participationOptions'] ?? []), + $addonResolution['items'], ); $signupCommand = new SignUpCommand($signupRequest); @@ -184,18 +192,41 @@ class SignupController extends CommonController { $siblingReduction = $request->input('sibling') === 'true'; - $amount = $event->calculateAmount( + $arrival = \DateTime::createFromFormat('Y-m-d', $request->input('arrival')); + $departure = \DateTime::createFromFormat('Y-m-d', $request->input('departure')); + + $baseFee = $event->calculateAmount( $request->input('participationType'), $request->input('beitrag'), - \DateTime::createFromFormat('Y-m-d', $request->input('arrival')), - \DateTime::createFromFormat('Y-m-d', $request->input('departure')), + $arrival, + $departure, $siblingReduction ); + // 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)); + $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/Routes/api.php b/app/Domains/Event/Routes/api.php index 34cb9b6..f91b460 100644 --- a/app/Domains/Event/Routes/api.php +++ b/app/Domains/Event/Routes/api.php @@ -46,6 +46,8 @@ 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']); + Route::post('/event-addons', [DetailsController::class, 'updateEventAddons']); }); diff --git a/app/Domains/Event/Views/Details.vue b/app/Domains/Event/Views/Details.vue index 8fdf945..6476ad8 100644 --- a/app/Domains/Event/Views/Details.vue +++ b/app/Domains/Event/Views/Details.vue @@ -1,9 +1,11 @@ + + + + diff --git a/app/Domains/Event/Views/Partials/Overview.vue b/app/Domains/Event/Views/Partials/Overview.vue index 920e544..0ea576d 100644 --- a/app/Domains/Event/Views/Partials/Overview.vue +++ b/app/Domains/Event/Views/Partials/Overview.vue @@ -1,15 +1,17 @@ + + + + diff --git a/app/Domains/Event/Views/Partials/ParticipantsByOption.vue b/app/Domains/Event/Views/Partials/ParticipantsByOption.vue new file mode 100644 index 0000000..bde4527 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ParticipantsByOption.vue @@ -0,0 +1,67 @@ + + + + + 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 new file mode 100644 index 0000000..f57333a --- /dev/null +++ b/app/Domains/Event/Views/Partials/ParticipationOptions.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 3d5e9ab..63f5f4c 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' @@ -23,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 = [ @@ -33,10 +35,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,14 +96,20 @@ 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..a68615b 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 ?? '', @@ -57,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 @@ -74,15 +79,13 @@ 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 } } - // 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. @@ -129,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/StepAddons.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue index ad1b60c..291db83 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..094eecd --- /dev/null +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue @@ -0,0 +1,62 @@ + + + + + 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..ed6f5f3 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue @@ -7,6 +7,8 @@ const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION' const props = defineProps({ formData: Object, event: Object, + summaryBaseAmount: {type: String, default: ''}, + summaryAddonLines: {type: Array, default: () => []}, summaryAmount: String, summaryAmountValue: {type: Number, default: 0}, summaryLoading: Boolean, @@ -32,8 +34,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.title || question.label, value: option?.label ?? ''} + }).filter(entry => entry.value !== '') +) const canSubmit = computed(() => props.formData.summary_information_correct @@ -115,6 +126,11 @@ function eatingHabit() { {{ participationGroup() }} + + {{ option.label }}: + {{ option.value }} + + Foto-Erlaubnis: @@ -149,6 +165,22 @@ function eatingHabit() { Abreise:{{ formatDate(formData.departure) }} +

Kostenübersicht

+ + + + + + + + + + + + + +
Teilnahmebeitrag:{{ summaryBaseAmount }}
{{ addon.title }}:{{ addon.free ? 'Kostenfrei' : addon.amount }}
Gesamtbetrag:{{ summaryAmount }}
+
+ + diff --git a/app/Enumerations/TaxExemptionReason.php b/app/Enumerations/TaxExemptionReason.php new file mode 100644 index 0000000..9493264 --- /dev/null +++ b/app/Enumerations/TaxExemptionReason.php @@ -0,0 +1,70 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 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::orderBy('sort_order')->get() + ->map(static fn (self $reason): array => [ + 'value' => $reason->slug, + 'label' => $reason->name, + 'text' => $reason->requires_note ? '' : $reason->invoice_text, + ]) + ->all(); + } +} diff --git a/app/Enumerations/VatPricingMode.php b/app/Enumerations/VatPricingMode.php new file mode 100644 index 0000000..ed5f9c0 --- /dev/null +++ b/app/Enumerations/VatPricingMode.php @@ -0,0 +1,38 @@ + 'Inklusive – Beiträge sind Endpreise (USt enthalten)', + self::AddOn => 'Aufschlagen – USt wird auf die Beiträge aufgeschlagen', + }; + } + + /** + * Optionen für das Frontend: [['value' => ..., 'label' => ...], ...]. + * + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $mode): array => [ + 'value' => $mode->value, + 'label' => $mode->label(), + ], + self::cases(), + ); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 20c26bd..049f2b4 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,13 @@ class Event extends InstancedModel 'support_flat', 'alcoholics_age', 'archived', + 'tax_liable', + 'vat_rate', + 'vat_pricing_mode', + 'tax_exemption_reason', + 'tax_exemption_note', + 'participation_options', + 'addons', ]; @@ -109,6 +113,12 @@ class Event extends InstancedModel 'total_max_amount' => AmountCast::class, 'sibling_reduction' => 'boolean', + + 'tax_liable' => 'boolean', + '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 23622a7..425aca8 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,18 @@ 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'); + } + + /** 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/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/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/Repositories/EventParticipantRepository.php b/app/Repositories/EventParticipantRepository.php index 467d93f..efba1e6 100644 --- a/app/Repositories/EventParticipantRepository.php +++ b/app/Repositories/EventParticipantRepository.php @@ -63,6 +63,122 @@ class EventParticipantRepository { return $participants; } + /** + * Gruppiert die (aktiven) Teilnehmenden nach ihren gewählten Teilnahmeoptionen. Je am Event definierter + * Frage entsteht eine Sektion mit Gruppen „Frage: Option" (in Definition-Reihenfolge); Teilnehmende ohne + * Antwort landen unter „Frage: (keine Angabe)". Ein Teilnehmer erscheint je Frage einmal, kann also über + * mehrere Fragen hinweg in mehreren Gruppen auftauchen. `$filter` liefert nur die eine passende Gruppe + * (für die E-Mail-Auflösung in ByGroupController). + */ + public function groupByParticipationOption(Event $event, Request $request, ?string $filter = null): array + { + $definitions = $event->participation_options ?? []; + $allParticipants = $this->getForList($event, $request); + $groups = []; + + foreach ($definitions as $question) { + $questionKey = $question['key'] ?? null; + if ($questionKey === null) { + continue; + } + + // Überschrift: bevorzugt der Titel, sonst die (längere) Frage bzw. der Key als Fallback. + $questionLabel = trim((string)($question['title'] ?? '')) !== '' + ? $question['title'] + : ($question['label'] ?? $questionKey); + $noAnswerKey = $questionLabel . ': (keine Angabe)'; + + // Vordefinierte Optionen in Reihenfolge: value => Gruppen-Key. + $definedGroupKeys = []; + foreach ($question['options'] ?? [] as $option) { + $definedGroupKeys[$option['value']] = $questionLabel . ': ' . ($option['label'] ?? $option['value']); + } + + $collected = []; + foreach ($allParticipants as $participant) { + $answer = null; + foreach ($participant['participationOptions'] ?? [] as $selectedOption) { + if (($selectedOption['questionKey'] ?? null) === $questionKey) { + $answer = $selectedOption; + break; + } + } + + if ($answer === null || ($answer['value'] ?? '') === '') { + $collected[$noAnswerKey][] = $participant; + continue; + } + + // Bevorzugt die definierte Option; falls sie zwischenzeitlich entfernt wurde, den Snapshot nutzen. + $groupKey = $definedGroupKeys[$answer['value']] ?? ($questionLabel . ': ' . ($answer['valueLabel'] ?? $answer['value'])); + $collected[$groupKey][] = $participant; + } + + // Ausgabe je Frage: definierte Optionen zuerst (in Reihenfolge), dann Snapshot-Reste, „(keine Angabe)" ans Ende. + foreach ($definedGroupKeys as $groupKey) { + if (!empty($collected[$groupKey])) { + $groups[$groupKey] = $collected[$groupKey]; + unset($collected[$groupKey]); + } + } + + foreach ($collected as $groupKey => $participants) { + if ($groupKey === $noAnswerKey) { + continue; + } + $groups[$groupKey] = $participants; + } + + if (!empty($collected[$noAnswerKey])) { + $groups[$noAnswerKey] = $collected[$noAnswerKey]; + } + } + + if ($filter !== null) { + return array_key_exists($filter, $groups) ? [$filter => $groups[$filter]] : []; + } + + 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 = []; 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/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..0a8c185 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -96,6 +96,10 @@ 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(), + '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 3055e94..81324ab 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; @@ -137,6 +138,23 @@ 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 ?? []; + + // 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'] = []; @@ -342,7 +360,61 @@ 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'); + } + + /** + * 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/app/Views/Components/Modal.vue b/app/Views/Components/Modal.vue index 77d513d..84c894c 100644 --- a/app/Views/Components/Modal.vue +++ b/app/Views/Components/Modal.vue @@ -32,7 +32,7 @@ @@ -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/app/Views/Components/TabbedPage.vue b/app/Views/Components/TabbedPage.vue index d6eb05b..66db2bf 100644 --- a/app/Views/Components/TabbedPage.vue +++ b/app/Views/Components/TabbedPage.vue @@ -1,5 +1,5 @@