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/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php index 518fbad..7196acf 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php +++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php @@ -89,11 +89,32 @@ 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 diff --git a/app/Domains/Event/Actions/SignUp/SignUpRequest.php b/app/Domains/Event/Actions/SignUp/SignUpRequest.php index de08ed7..cec782f 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php +++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php @@ -48,6 +48,8 @@ class SignUpRequest { 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 91883e8..1f614c8 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php @@ -77,11 +77,45 @@ class UpdateParticipantCommand { $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, diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php index 8d6166a..ccda46d 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php @@ -38,5 +38,7 @@ class UpdateParticipantRequest { 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 bb204ca..a197c4e 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -2,6 +2,8 @@ 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; @@ -155,6 +157,17 @@ class DetailsController extends CommonController { 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); diff --git a/app/Domains/Event/Controllers/ParticipantUpdateController.php b/app/Domains/Event/Controllers/ParticipantUpdateController.php index 4d07f40..50911aa 100644 --- a/app/Domains/Event/Controllers/ParticipantUpdateController.php +++ b/app/Domains/Event/Controllers/ParticipantUpdateController.php @@ -45,6 +45,7 @@ class ParticipantUpdateController extends CommonController { 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 ad776a1..7dbfefa 100644 --- a/app/Domains/Event/Controllers/SignupController.php +++ b/app/Domains/Event/Controllers/SignupController.php @@ -104,6 +104,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, @@ -143,6 +148,7 @@ class SignupController extends CommonController { $registrationData['paymentMethod'] ?? null, (array)($registrationData['paymentOptions'] ?? []), (array)($registrationData['participationOptions'] ?? []), + $addonResolution['items'], ); $signupCommand = new SignUpCommand($signupRequest); @@ -185,14 +191,22 @@ class SignupController extends CommonController { $siblingReduction = $request->input('sibling') === 'true'; + $arrival = \DateTime::createFromFormat('Y-m-d', $request->input('arrival')); + $departure = \DateTime::createFromFormat('Y-m-d', $request->input('departure')); + $amount = $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)); + $amount->addAmount($event->resolveAddons($selectedAddonKeys, $arrival, $departure)['total']); + // amountValue (numerisch) steuert im Frontend das Überspringen des Zahlungsart-Schritts bei 0 €. return response()->json([ 'amount' => $amount->toString(), diff --git a/app/Domains/Event/Routes/api.php b/app/Domains/Event/Routes/api.php index 760e935..f91b460 100644 --- a/app/Domains/Event/Routes/api.php +++ b/app/Domains/Event/Routes/api.php @@ -47,6 +47,7 @@ Route::prefix('api/v1') 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/Partials/EventAddons.vue b/app/Domains/Event/Views/Partials/EventAddons.vue new file mode 100644 index 0000000..008535c --- /dev/null +++ b/app/Domains/Event/Views/Partials/EventAddons.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/app/Domains/Event/Views/Partials/Overview.vue b/app/Domains/Event/Views/Partials/Overview.vue index 26607b7..0ea576d 100644 --- a/app/Domains/Event/Views/Partials/Overview.vue +++ b/app/Domains/Event/Views/Partials/Overview.vue @@ -2,6 +2,7 @@ import {onMounted, reactive, ref} from "vue"; import ParticipationFees from "./ParticipationFees.vue"; import ParticipationOptions from "./ParticipationOptions.vue"; +import EventAddons from "./EventAddons.vue"; import ParticipationSummary from "./ParticipationSummary.vue"; import CommonSettings from "./CommonSettings.vue"; import EventManagement from "./EventManagement.vue"; @@ -41,6 +42,10 @@ const props = defineProps({ displayData.value = 'participationOptions'; } +async function showEventAddons() { + displayData.value = 'eventAddons'; +} + async function showEventManagement() { displayData.value = 'eventManagement'; } @@ -114,6 +119,7 @@ const props = defineProps({ + @@ -194,6 +200,8 @@ const props = defineProps({     +   + Budget bearbeiten Ausgabenübersicht Archivieren diff --git a/app/Domains/Event/Views/Partials/ParticipantData.vue b/app/Domains/Event/Views/Partials/ParticipantData.vue index 38d02e2..2180096 100644 --- a/app/Domains/Event/Views/Partials/ParticipantData.vue +++ b/app/Domains/Event/Views/Partials/ParticipantData.vue @@ -61,17 +61,26 @@ const form = reactive({ amountExpected: '', cocStatus: '', participationOptions: {}, + selectedAddons: {}, }); // Teilnahmeoptionen des Events (Definition mit Titel/Frage/Optionen). const participationQuestions = computed(() => staticProps.event?.participationOptions ?? []); +// Zusatzoptionen (Event-Addons) des Events. +const eventAddons = computed(() => staticProps.event?.addons ?? []); + // Gewählte Antwort (Options-Label) eines Teilnehmers zu einer Frage – für die Ansicht „Titel: Antwort". function answerLabelFor(questionKey) { const answer = (props.participant?.participationOptions ?? []).find(o => o.questionKey === questionKey); return answer?.valueLabel ?? '—'; } +// Hat der Teilnehmer diesen Zusatz gebucht? – für die Ansicht. +function isAddonSelected(addonKey) { + return (props.participant?.selectedAddons ?? []).some(a => a.addonKey === addonKey); +} + watch( () => props.participant, (participant) => { @@ -107,6 +116,9 @@ watch( form.participationOptions = Object.fromEntries( (participant?.participationOptions ?? []).map(o => [o.questionKey, o.value]) ); + form.selectedAddons = Object.fromEntries( + (participant?.selectedAddons ?? []).map(a => [a.addonKey, true]) + ); }, { immediate: true } ); @@ -402,25 +414,41 @@ function saveParticipant() { -
+
-

Teilnahmeoptionen

- - - - - -
{{ 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'); + } +};