request->paymentMethod !== null ? EventPaymentModuleRegistry::forSlug($this->request->paymentMethod) : null; $paymentOptions = $module?->sanitizeParticipantOptions($this->request->paymentOptions) ?? []; if ($module !== null && !$module->participantOptionsComplete($paymentOptions)) { $response->success = false; $response->message = 'Bitte alle erforderlichen Zahlungsangaben ausfüllen.'; 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, default => EatingHabit::EATING_HABIT_OMNIVOR, }; $participantAge = new Age($this->request->birthday); // Anmeldung als Ganzes: die laufende Nummer (invoice_sequence) muss unter der Sperre vergeben und // im selben Zug geschrieben werden, sonst könnten zwei gleichzeitige Anmeldungen dieselbe erhalten. $response->participant = DB::transaction(function () use ($participantAge, $eatingHabit, $paymentOptions, $participationOptionRows) { $participant = $this->request->event->participants()->create( [ 'tenant' => $this->request->event->tenant, 'user_id' => $this->request->user_id, 'identifier' => Str::random(10), 'invoice_sequence' => $this->nextInvoiceSequence(), 'firstname' => $this->request->firstname, 'lastname' => $this->request->lastname, 'nickname' => $this->request->nickname, 'participation_type' => $this->request->participationType, 'fee_type' => $this->request->feeType, 'sibling_reduction' => $this->request->siblingReduction, 'local_group' => $this->request->localGroup?->slug, 'birthday' => $this->request->birthday, 'address_1' => $this->request->address_1, 'address_2' => $this->request->address_2, 'postcode' => $this->request->postcode, 'city' => $this->request->city, 'email_1' => $this->request->email_1, 'email_2' => $this->request->email_2, 'phone_1' => $this->request->phone_1, 'phone_2' => $this->request->phone_2, 'contact_person' => $this->request->contact_person, 'allergies' => $this->request->allergies, 'intolerances' => $this->request->intolerances, 'medications' => $this->request->medications, 'tetanus_vaccination' => $this->request->tetanus_vaccination, 'eating_habit' => $eatingHabit, 'swimming_permission' => $this->resolveSwimmingPermission($participantAge), 'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1') ? 'FIRST_AID_PERMISSION_ALLOWED' : $this->request->first_aid_permission, 'foto_socialmedia' => $this->request->foto_socialmedia, 'foto_print' => $this->request->foto_print, 'foto_webseite' => $this->request->foto_webseite, 'foto_partner' => $this->request->foto_partner, 'foto_intern' => $this->request->foto_intern, 'arrival_date' => $this->request->arrival, 'departure_date' => $this->request->departure, 'arrival_eating' => $this->request->arrival_eating, 'departure_eating' => $this->request->departure_eating, 'notes' => $this->request->notes, 'amount' => $this->request->amount, 'payment_purpose' => $this->request->event->name . ' - Beitrag ' . $this->request->firstname . ' ' . $this->request->lastname, 'payment_method' => $this->request->paymentMethod, 'payment_options' => $paymentOptions, 'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED, ] ); $this->persistParticipationOptions($participant, $participationOptionRows); $this->persistAddons($participant); return $participant; }); $response->success = true; return $response; } /** * Badeerlaubnis auflösen. Volljährige brauchen keine Erlaubnis. Für Minderjährige gilt die getroffene Auswahl; * fehlt sie -- weil die Veranstaltung die Badeerlaubnis gar nicht abfragt oder keine Wahl getroffen wurde --, * wird bewusst "Keine" hinterlegt und nicht etwa stillschweigend eine Erlaubnis angenommen. */ private function resolveSwimmingPermission(Age $participantAge): string { if ($participantAge->isfullAged()) { return SwimmingPermission::SWIMMING_PERMISSION_ALLOWED; } if (!$this->request->event->swimming_permission_required || $this->request->swimming_permission === null || $this->request->swimming_permission === '-1') { return SwimmingPermission::SWIMMING_PERMISSION_DENIED; } return $this->request->swimming_permission; } /** * Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der * Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben * Veranstaltung serialisiert werden; der Aufruf erfolgt innerhalb der Anmelde-Transaktion, sodass die * Sperre bis zum Schreiben des Teilis steht. * * Abgemeldete Teilis behalten ihre Nummer, gezählt wird deshalb über MAX und nicht über COUNT. */ private function nextInvoiceSequence(): int { DB::table('events') ->where('id', $this->request->event->id) ->lockForUpdate() ->first(); $highest = DB::table('event_participants') ->where('event_id', $this->request->event->id) ->max('invoice_sequence'); return (int) $highest + 1; } /** * 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, ])); } } }