From 328825293a0b4ce8dc6ab8a6d6b990119f7e8c71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Thu, 10 Sep 2026 22:27:57 +0200
Subject: [PATCH] =?UTF-8?q?Fix=20f=C3=BCr=20Short-Signups?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ShortSignUp/ShortSignUpCommand.php | 18 ++++--
.../ShortSignUp/ShortSignUpRequest.php | 5 ++
.../ShortCalculateAmountController.php | 44 +++++++------
.../Controllers/ShortSignupController.php | 4 ++
.../ShortSignUpForm/ShortSignupForm.vue | 14 ++++-
.../composables/shortStepFlow.js | 29 +++++----
.../composables/useShortSignupForm.js | 50 ++++++++++-----
.../ShortSignUpForm/steps/ShortStepFee.vue | 33 ++++++++++
.../steps/ShortStepSummary.vue | 7 ++-
app/Models/Event.php | 11 +++-
app/RelationModels/EventParticipationFee.php | 21 +++++++
tests/Feature/ShortSignUpTest.php | 61 ++++++++++++++++++-
version | 2 +-
13 files changed, 242 insertions(+), 57 deletions(-)
create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepFee.vue
diff --git a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php
index cb187f0..d8b651f 100644
--- a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php
+++ b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php
@@ -29,12 +29,20 @@ class ShortSignUpCommand {
// Ohne konfigurierte Teilnahmegruppe gäbe es keinen gültigen `participation_type` -- sauber abbrechen,
// statt in einen Fremdschlüssel-Fehler zu laufen.
- $participationType = $this->request->event->firstParticipationTypeSlug();
- if ($participationType === null) {
+ $participationFee = $this->request->event->firstParticipationFee();
+ if ($participationFee === null) {
$response->message = 'Für diese Veranstaltung ist noch keine Teilnahmegruppe hinterlegt.';
return $response;
}
+ $participationType = $participationFee->type;
+
+ // Nur hinterlegte Beitragsstufen sind wählbar -- für jede andere hätte calculateAmount() keinen Betrag.
+ if (!in_array($this->request->feeType, $participationFee->availableFeeTypes(), true)) {
+ $response->message = 'Der gewählte Beitrag ist für diese Veranstaltung nicht verfügbar.';
+ return $response;
+ }
+
$participantAge = new Age($this->request->birthday);
// Bei Minderjährigen ist die Kontaktperson Pflicht; der Client prüft das ebenfalls, verlassen wird sich
@@ -63,10 +71,10 @@ class ShortSignUpCommand {
$eventResource = $this->request->event->toResource();
- // Zeitraum, Gruppe und Beitragsstufe stehen bei der Kurzanmeldung fest -- es gibt nichts zu wählen.
+ // Zeitraum und Gruppe stehen bei der Kurzanmeldung fest, wählbar ist nur die Beitragsstufe.
$amount = $eventResource->calculateAmount(
$participationType,
- 'standard',
+ $this->request->feeType,
$this->request->event->start_date,
$this->request->event->end_date,
false
@@ -113,7 +121,7 @@ class ShortSignUpCommand {
paymentOptions: $this->request->paymentOptions,
participationOptions: $this->request->participationOptions,
addonItems: [],
- feeType: 'standard',
+ feeType: $this->request->feeType,
siblingReduction: false,
);
diff --git a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php
index 472846b..18b7b46 100644
--- a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php
+++ b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php
@@ -42,6 +42,11 @@ class ShortSignUpRequest {
public array $paymentOptions = [],
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */
public array $participationOptions = [],
+ /**
+ * Beitragsstufe (`standard`, `reduced`, `solidarity`). Wählbar nur, wenn die Teilnahmegruppe neben dem
+ * Standardbeitrag weitere Stufen hinterlegt hat; der Command prüft das.
+ */
+ public string $feeType = 'standard',
) {
}
}
diff --git a/app/Domains/Event/Controllers/ShortCalculateAmountController.php b/app/Domains/Event/Controllers/ShortCalculateAmountController.php
index 03a4999..155e955 100644
--- a/app/Domains/Event/Controllers/ShortCalculateAmountController.php
+++ b/app/Domains/Event/Controllers/ShortCalculateAmountController.php
@@ -2,35 +2,45 @@
namespace App\Domains\Event\Controllers;
+use App\RelationModels\EventParticipationFee;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
/**
- * Beitrag einer Kurzanmeldung. Teilnahmegruppe, Beitragsstufe und Zeitraum stehen fest, deshalb braucht der
- * Endpunkt keine Eingaben -- und der Betrag lässt sich vom Client nicht beeinflussen.
+ * Beiträge einer Kurzanmeldung. Teilnahmegruppe und Zeitraum stehen fest; geliefert wird der Betrag je
+ * hinterlegter Beitragsstufe, damit der Client zwischen ihnen wählen kann, ohne erneut zu fragen. Der Endpunkt
+ * braucht keine Eingaben -- berechnet wird der gespeicherte Betrag bei der Anmeldung ohnehin neu.
*
- * `amountValue` steuert im Frontend, ob der Schritt "Zahlungsart" gezeigt wird.
+ * `amountValue` der gewählten Stufe steuert im Frontend, ob der Schritt "Zahlungsart" gezeigt wird; mehr als
+ * eine Stufe blendet den Schritt "Beitrag" ein.
*/
class ShortCalculateAmountController extends CommonController {
public function __invoke(int $eventId) : JsonResponse {
$event = $this->events->getById($eventId, false);
- $participationType = $event->firstParticipationTypeSlug();
+ $participationFee = $event->firstParticipationFee();
+ $eventResource = $event->toResource();
- $amount = $participationType === null
- ? new Amount(0, 'Euro')
- : $event->toResource()->calculateAmount(
- $participationType,
- 'standard',
- $event->start_date,
- $event->end_date,
- false
- );
+ $feeTypes = array_map(function (string $feeType) use ($event, $eventResource, $participationFee) {
+ $amount = $participationFee === null
+ ? new Amount(0, 'Euro')
+ : $eventResource->calculateAmount(
+ $participationFee->type,
+ $feeType,
+ $event->start_date,
+ $event->end_date,
+ false
+ );
- return response()->json([
- 'amount' => $amount->toString(),
- 'amountValue' => $amount->getAmount(),
- ]);
+ return [
+ 'value' => $feeType,
+ 'label' => EventParticipationFee::FEE_TYPE_LABELS[$feeType],
+ 'amount' => $amount->toString(),
+ 'amountValue' => $amount->getAmount(),
+ ];
+ }, $participationFee?->availableFeeTypes() ?? ['standard']);
+
+ return response()->json(['feeTypes' => $feeTypes]);
}
}
diff --git a/app/Domains/Event/Controllers/ShortSignupController.php b/app/Domains/Event/Controllers/ShortSignupController.php
index fd26191..b696f96 100644
--- a/app/Domains/Event/Controllers/ShortSignupController.php
+++ b/app/Domains/Event/Controllers/ShortSignupController.php
@@ -10,6 +10,7 @@ use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Providers\DoubleCheckEventRegistrationProvider;
+use App\RelationModels\EventParticipationFee;
use App\Scopes\CommonController;
use DateTime;
use Illuminate\Http\JsonResponse;
@@ -53,6 +54,8 @@ class ShortSignupController extends CommonController {
'participationOptions' => 'array',
'paymentMethod' => 'nullable|string',
'paymentOptions' => 'array',
+ // Ob die Stufe für die Teilnahmegruppe hinterlegt ist, prüft der Command.
+ 'feeType' => 'nullable|string|in:' . implode(',', array_keys(EventParticipationFee::FEE_TYPE_LABELS)),
]);
if ($validator->fails()) {
@@ -99,6 +102,7 @@ class ShortSignupController extends CommonController {
paymentMethod: $request->input('paymentMethod') ?: null,
paymentOptions: (array)$request->input('paymentOptions', []),
participationOptions: (array)$request->input('participationOptions', []),
+ feeType: $request->input('feeType') ?: 'standard',
);
$shortSignUpResponse = new ShortSignUpCommand($shortSignUpRequest)->execute();
diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue
index c009637..ce33afb 100644
--- a/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue
+++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue
@@ -3,6 +3,7 @@ import {computed} from 'vue'
import {useShortSignupForm} from './composables/useShortSignupForm.js'
import {
SHORT_STEP_PERSON,
+ SHORT_STEP_FEE,
SHORT_STEP_PARTICIPATION_OPTIONS,
SHORT_STEP_PHOTO,
SHORT_STEP_PAYMENT,
@@ -10,6 +11,7 @@ import {
stepVisible,
} from './composables/shortStepFlow.js'
import ShortStepPerson from './steps/ShortStepPerson.vue'
+import ShortStepFee from './steps/ShortStepFee.vue'
import ShortStepSummary from './steps/ShortStepSummary.vue'
import StepParticipationOptions from '../SignUpForm/steps/StepParticipationOptions.vue'
import StepPhotoPermissions from '../SignUpForm/steps/StepPhotoPermissions.vue'
@@ -25,7 +27,7 @@ const props = defineProps({
const {
currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup,
submit, submitting, submitResult, submitError,
- summaryLoading, summaryAmount, summaryAmountValue,
+ summaryLoading, summaryAmount, feeOptions, selectedFee, flowState,
} = useShortSignupForm(props.event, props.participantData)
// Die aus dem langen Prozess übernommenen Schritte emittieren ihre Zielnummer aus jenem Flow mit. Hier zählt
@@ -35,6 +37,7 @@ const onBack = () => goBack()
const allSteps = [
{step: SHORT_STEP_PERSON, label: 'Person'},
+ {step: SHORT_STEP_FEE, label: 'Beitrag'},
{step: SHORT_STEP_PARTICIPATION_OPTIONS, label: 'Teilnahmeoptionen'},
{step: SHORT_STEP_PHOTO, label: 'Fotoerlaubnis'},
{step: SHORT_STEP_PAYMENT, label: 'Zahlungsart'},
@@ -43,7 +46,7 @@ const allSteps = [
// Nur die tatsächlich vorkommenden Schritte anzeigen, sonst zeigt die Leiste Schritte, die nie erscheinen.
const steps = computed(() =>
- allSteps.filter(s => stepVisible(props.event, s.step, summaryAmountValue.value))
+ allSteps.filter(s => stepVisible(props.event, s.step, flowState.value))
)
const currentIndex = computed(() => steps.value.findIndex(s => s.step === currentStep.value))
@@ -101,6 +104,11 @@ const currentIndex = computed(() => steps.value.findIndex(s => s.step === curren
:localGroups="localGroups" :needsLocalGroup="needsLocalGroup"
@next="onNext"
/>
+
steps.value.findIndex(s => s.step === curren
:event="event"
:isMinor="isMinor"
:summaryAmount="summaryAmount"
- :summaryAmountValue="summaryAmountValue"
+ :feeLabel="feeOptions.length > 1 ? selectedFee?.label : null"
:summaryLoading="summaryLoading"
:submitting="submitting"
:submitError="submitError"
diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js
index 465b472..6bfb4a1 100644
--- a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js
+++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js
@@ -1,36 +1,43 @@
-// Schrittreihenfolge der Kurzanmeldung. Zwei Schritte sind optional: die Teilnahmeoptionen entfallen, wenn für
+// Schrittreihenfolge der Kurzanmeldung. Drei Schritte sind optional: der Beitrag erscheint nur, wenn neben dem
+// Standardbeitrag weitere Stufen (reduziert, Solidarität) hinterlegt sind, die Teilnahmeoptionen entfallen, wenn für
// die Veranstaltung keine definiert sind, die Zahlungsart entfällt bei Betrag 0 oder wenn es ohnehin nur eine
// Zahlungsart gibt (die wird dann vorausgewählt).
export const SHORT_STEP_PERSON = 1
-export const SHORT_STEP_PARTICIPATION_OPTIONS = 2
-export const SHORT_STEP_PHOTO = 3
-export const SHORT_STEP_PAYMENT = 4
-export const SHORT_STEP_SUMMARY = 5
+export const SHORT_STEP_FEE = 2
+export const SHORT_STEP_PARTICIPATION_OPTIONS = 3
+export const SHORT_STEP_PHOTO = 4
+export const SHORT_STEP_PAYMENT = 5
+export const SHORT_STEP_SUMMARY = 6
+
+// `state` kommt mit den Beiträgen vom Server: { amountValue: Betrag der gewählten Stufe, feeOptionCount }.
+export function stepVisible(event, step, state) {
+ if (step === SHORT_STEP_FEE) {
+ return state.feeOptionCount > 1
+ }
-export function stepVisible(event, step, amountValue) {
if (step === SHORT_STEP_PARTICIPATION_OPTIONS) {
return (event.participationOptions?.length ?? 0) > 0
}
if (step === SHORT_STEP_PAYMENT) {
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
- return amountValue > 0 && activeMethods.length > 1
+ return state.amountValue > 0 && activeMethods.length > 1
}
return true
}
-export function nextVisibleFrom(event, step, amountValue) {
+export function nextVisibleFrom(event, step, state) {
let candidate = step + 1
- while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, amountValue)) {
+ while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, state)) {
candidate++
}
return candidate
}
-export function prevVisibleFrom(event, step, amountValue) {
+export function prevVisibleFrom(event, step, state) {
let candidate = step - 1
- while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, amountValue)) {
+ while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, state)) {
candidate--
}
return candidate
diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js
index b591bd1..f645f93 100644
--- a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js
+++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js
@@ -1,4 +1,4 @@
-import {computed, reactive, ref} from 'vue'
+import {computed, reactive, ref, watch} from 'vue'
import axios from 'axios'
import {differenceInYears, parseISO} from 'date-fns'
import {SHORT_STEP_PERSON, nextVisibleFrom, prevVisibleFrom} from './shortStepFlow.js'
@@ -33,6 +33,8 @@ export function useShortSignupForm(event, participantData) {
intolerances: participantData.intolerances ?? '',
first_aid: '-1',
badeerlaubnis: '-1',
+ // Beitragsstufe; wählbar nur, wenn weitere Stufen hinterlegt sind.
+ feeType: 'standard',
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
participationOptions: {},
foto: {socialmedia: false, print: false, webseite: false, partner: false, intern: false},
@@ -53,12 +55,18 @@ export function useShortSignupForm(event, participantData) {
return differenceInYears(new Date(), parseISO(formData.geburtsdatum)) < 18
})
- const summaryAmount = ref('')
- const summaryAmountValue = ref(0)
+ // Beitragsstufen mit fertig berechnetem Betrag: [{ value, label, amount, amountValue }]
+ const feeOptions = ref([])
const amountLoaded = ref(false)
- // Der Betrag steht für die ganze Anmeldung fest (feste Teilnahmegruppe, fester Zeitraum) und wird deshalb
- // genau einmal geholt. Er entscheidet, ob der Zahlungsschritt überhaupt vorkommt.
+ const selectedFee = computed(() =>
+ feeOptions.value.find(option => option.value === formData.feeType) ?? feeOptions.value[0] ?? null
+ )
+ const summaryAmount = computed(() => selectedFee.value?.amount ?? '')
+ const summaryAmountValue = computed(() => Number(selectedFee.value?.amountValue ?? 0))
+
+ // Teilnahmegruppe und Zeitraum stehen für die ganze Anmeldung fest, deshalb werden die Beträge aller Stufen
+ // genau einmal geholt. Die Wahl der Stufe entscheidet dann nur noch lokal über den Betrag.
const ensureAmount = async () => {
if (amountLoaded.value) {
return
@@ -67,31 +75,41 @@ export function useShortSignupForm(event, participantData) {
summaryLoading.value = true
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount')
- summaryAmount.value = res.data.amount
- summaryAmountValue.value = Number(res.data.amountValue ?? 0)
+ feeOptions.value = res.data.feeTypes ?? []
amountLoaded.value = true
-
- if (summaryAmountValue.value <= 0) {
- formData.paymentMethod = null
- formData.paymentOptions = {}
- }
} finally {
summaryLoading.value = false
}
}
+ // Bei Betrag 0 gibt es nichts zu bezahlen. Wechselt die Stufe zurück auf einen Betrag, wird die einzige
+ // Zahlungsart wieder vorausgewählt -- sonst fragt sie niemand mehr ab.
+ watch(summaryAmountValue, (value) => {
+ if (value <= 0) {
+ formData.paymentMethod = null
+ formData.paymentOptions = {}
+ } else if (formData.paymentMethod === null && activeMethods.length === 1) {
+ formData.paymentMethod = activeMethods[0].slug
+ }
+ })
+
+ const flowState = computed(() => ({
+ amountValue: summaryAmountValue.value,
+ feeOptionCount: feeOptions.value.length,
+ }))
+
const goToStep = (step) => {
currentStep.value = step
}
const goNext = async () => {
await ensureAmount()
- goToStep(nextVisibleFrom(event, currentStep.value, summaryAmountValue.value))
+ goToStep(nextVisibleFrom(event, currentStep.value, flowState.value))
}
const goBack = async () => {
await ensureAmount()
- goToStep(prevVisibleFrom(event, currentStep.value, summaryAmountValue.value))
+ goToStep(prevVisibleFrom(event, currentStep.value, flowState.value))
}
const submit = async () => {
@@ -120,6 +138,7 @@ export function useShortSignupForm(event, participantData) {
participationOptions: formData.participationOptions,
paymentMethod: formData.paymentMethod,
paymentOptions: formData.paymentOptions,
+ feeType: formData.feeType,
})
submitResult.value = {
@@ -150,6 +169,9 @@ export function useShortSignupForm(event, participantData) {
summaryLoading,
summaryAmount,
summaryAmountValue,
+ feeOptions,
+ selectedFee,
+ flowState,
ensureAmount,
}
}
diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepFee.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepFee.vue
new file mode 100644
index 0000000..e0b4dbc
--- /dev/null
+++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepFee.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
Teilnahmebeitrag
+
+ Bitte wähle deinen Beitrag für die gesamte Veranstaltung.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue
index 504eb19..1b1b7c4 100644
--- a/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue
+++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue
@@ -8,7 +8,8 @@ const props = defineProps({
event: Object,
isMinor: Boolean,
summaryAmount: String,
- summaryAmountValue: {type: Number, default: 0},
+ // Nur gesetzt, wenn zwischen mehreren Beitragsstufen gewählt werden konnte.
+ feeLabel: {type: String, default: null},
summaryLoading: Boolean,
submitting: Boolean,
submitError: {type: String, default: ''},
@@ -137,6 +138,10 @@ const canSubmit = computed(() =>
Kostenübersicht