6 Commits
Author SHA1 Message Date
th.guenther 985c9ce07e Deployment-Rules for second server 2026-09-26 11:51:49 +02:00
th.guenther 790e8e1dbc Fix im Anmeldungs-Ende 2026-09-11 13:23:53 +02:00
th.guenther ef6284d5cc Fehler in Abrechnungsende behoben 2026-09-11 11:26:35 +02:00
th.guenther 328825293a Fix für Short-Signups 2026-09-10 22:27:57 +02:00
th.guenther 4e981c140b version aktualisiert 2026-09-10 19:28:27 +02:00
th.guenther 8940cb275b Merge pull request 'Dev 4.9.0' (#17) from dev-4.9.0 into main
Reviewed-on: #17
2026-09-10 19:23:16 +02:00
35 changed files with 1452 additions and 79 deletions

No files matched your search

@@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand {
$this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount(); $this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount();
$this->request->costUnit->mail_on_new = $this->request->mailOnNew; $this->request->costUnit->mail_on_new = $this->request->mailOnNew;
$this->request->costUnit->billing_deadline = $this->request->billingDeadline; $this->request->costUnit->billing_deadline = $this->request->billingDeadline?->format('Y-m-d');
$response->success = $this->request->costUnit->save(); $response->success = $this->request->costUnit->save();
return $response; return $response;
@@ -17,7 +17,7 @@ class CreateCostUnitCommand {
'name' => $this->request->name, 'name' => $this->request->name,
'tenant' => currentTenant()->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->type, 'type' => $this->request->type,
'billing_deadline' => $this->request->billingDeadline, 'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'),
'distance_allowance' => $this->request->distanceAllowance->getAmount(), 'distance_allowance' => $this->request->distanceAllowance->getAmount(),
'mail_on_new' => $this->request->mailOnNew, 'mail_on_new' => $this->request->mailOnNew,
'allow_new' => true, 'allow_new' => true,
@@ -37,7 +37,10 @@ class EditController extends CommonController{
$saveParams = $request->get('formData'); $saveParams = $request->get('formData');
$distanceAllowance = Amount::fromString($saveParams['distanceAllowance']); $distanceAllowance = Amount::fromString($saveParams['distanceAllowance']);
$billingDeadline = isset($saveParams['billingDeadline']) ? \DateTime::createFromFormat('Y-m-d', $saveParams['billingDeadline']) : null; $rawBillingDeadline = $saveParams['billingDeadline'] ?? null;
$billingDeadline = !empty($rawBillingDeadline)
? (\DateTime::createFromFormat('!Y-m-d', $rawBillingDeadline) ?: null)
: null;
$request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline); $request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline);
$command = new ChangeCostUnitDetailsCommand($request); $command = new ChangeCostUnitDetailsCommand($request);
@@ -29,12 +29,20 @@ class ShortSignUpCommand {
// Ohne konfigurierte Teilnahmegruppe gäbe es keinen gültigen `participation_type` -- sauber abbrechen, // Ohne konfigurierte Teilnahmegruppe gäbe es keinen gültigen `participation_type` -- sauber abbrechen,
// statt in einen Fremdschlüssel-Fehler zu laufen. // statt in einen Fremdschlüssel-Fehler zu laufen.
$participationType = $this->request->event->firstParticipationTypeSlug(); $participationFee = $this->request->event->firstParticipationFee();
if ($participationType === null) { if ($participationFee === null) {
$response->message = 'Für diese Veranstaltung ist noch keine Teilnahmegruppe hinterlegt.'; $response->message = 'Für diese Veranstaltung ist noch keine Teilnahmegruppe hinterlegt.';
return $response; 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); $participantAge = new Age($this->request->birthday);
// Bei Minderjährigen ist die Kontaktperson Pflicht; der Client prüft das ebenfalls, verlassen wird sich // 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(); $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( $amount = $eventResource->calculateAmount(
$participationType, $participationType,
'standard', $this->request->feeType,
$this->request->event->start_date, $this->request->event->start_date,
$this->request->event->end_date, $this->request->event->end_date,
false false
@@ -113,7 +121,7 @@ class ShortSignUpCommand {
paymentOptions: $this->request->paymentOptions, paymentOptions: $this->request->paymentOptions,
participationOptions: $this->request->participationOptions, participationOptions: $this->request->participationOptions,
addonItems: [], addonItems: [],
feeType: 'standard', feeType: $this->request->feeType,
siblingReduction: false, siblingReduction: false,
); );
@@ -42,6 +42,11 @@ class ShortSignUpRequest {
public array $paymentOptions = [], public array $paymentOptions = [],
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */ /** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */
public array $participationOptions = [], 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',
) { ) {
} }
} }
@@ -2,35 +2,45 @@
namespace App\Domains\Event\Controllers; namespace App\Domains\Event\Controllers;
use App\RelationModels\EventParticipationFee;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
/** /**
* Beitrag einer Kurzanmeldung. Teilnahmegruppe, Beitragsstufe und Zeitraum stehen fest, deshalb braucht der * Beiträge einer Kurzanmeldung. Teilnahmegruppe und Zeitraum stehen fest; geliefert wird der Betrag je
* Endpunkt keine Eingaben -- und der Betrag lässt sich vom Client nicht beeinflussen. * 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 { class ShortCalculateAmountController extends CommonController {
public function __invoke(int $eventId) : JsonResponse { public function __invoke(int $eventId) : JsonResponse {
$event = $this->events->getById($eventId, false); $event = $this->events->getById($eventId, false);
$participationType = $event->firstParticipationTypeSlug(); $participationFee = $event->firstParticipationFee();
$eventResource = $event->toResource();
$amount = $participationType === null $feeTypes = array_map(function (string $feeType) use ($event, $eventResource, $participationFee) {
? new Amount(0, 'Euro') $amount = $participationFee === null
: $event->toResource()->calculateAmount( ? new Amount(0, 'Euro')
$participationType, : $eventResource->calculateAmount(
'standard', $participationFee->type,
$event->start_date, $feeType,
$event->end_date, $event->start_date,
false $event->end_date,
); false
);
return response()->json([ return [
'amount' => $amount->toString(), 'value' => $feeType,
'amountValue' => $amount->getAmount(), 'label' => EventParticipationFee::FEE_TYPE_LABELS[$feeType],
]); 'amount' => $amount->toString(),
'amountValue' => $amount->getAmount(),
];
}, $participationFee?->availableFeeTypes() ?? ['standard']);
return response()->json(['feeTypes' => $feeTypes]);
} }
} }
@@ -10,6 +10,7 @@ use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission; use App\Enumerations\SwimmingPermission;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail; use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Providers\DoubleCheckEventRegistrationProvider; use App\Providers\DoubleCheckEventRegistrationProvider;
use App\RelationModels\EventParticipationFee;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use DateTime; use DateTime;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -53,6 +54,8 @@ class ShortSignupController extends CommonController {
'participationOptions' => 'array', 'participationOptions' => 'array',
'paymentMethod' => 'nullable|string', 'paymentMethod' => 'nullable|string',
'paymentOptions' => 'array', '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()) { if ($validator->fails()) {
@@ -99,6 +102,7 @@ class ShortSignupController extends CommonController {
paymentMethod: $request->input('paymentMethod') ?: null, paymentMethod: $request->input('paymentMethod') ?: null,
paymentOptions: (array)$request->input('paymentOptions', []), paymentOptions: (array)$request->input('paymentOptions', []),
participationOptions: (array)$request->input('participationOptions', []), participationOptions: (array)$request->input('participationOptions', []),
feeType: $request->input('feeType') ?: 'standard',
); );
$shortSignUpResponse = new ShortSignUpCommand($shortSignUpRequest)->execute(); $shortSignUpResponse = new ShortSignUpCommand($shortSignUpRequest)->execute();
@@ -3,6 +3,7 @@ import {computed} from 'vue'
import {useShortSignupForm} from './composables/useShortSignupForm.js' import {useShortSignupForm} from './composables/useShortSignupForm.js'
import { import {
SHORT_STEP_PERSON, SHORT_STEP_PERSON,
SHORT_STEP_FEE,
SHORT_STEP_PARTICIPATION_OPTIONS, SHORT_STEP_PARTICIPATION_OPTIONS,
SHORT_STEP_PHOTO, SHORT_STEP_PHOTO,
SHORT_STEP_PAYMENT, SHORT_STEP_PAYMENT,
@@ -10,6 +11,7 @@ import {
stepVisible, stepVisible,
} from './composables/shortStepFlow.js' } from './composables/shortStepFlow.js'
import ShortStepPerson from './steps/ShortStepPerson.vue' import ShortStepPerson from './steps/ShortStepPerson.vue'
import ShortStepFee from './steps/ShortStepFee.vue'
import ShortStepSummary from './steps/ShortStepSummary.vue' import ShortStepSummary from './steps/ShortStepSummary.vue'
import StepParticipationOptions from '../SignUpForm/steps/StepParticipationOptions.vue' import StepParticipationOptions from '../SignUpForm/steps/StepParticipationOptions.vue'
import StepPhotoPermissions from '../SignUpForm/steps/StepPhotoPermissions.vue' import StepPhotoPermissions from '../SignUpForm/steps/StepPhotoPermissions.vue'
@@ -25,7 +27,7 @@ const props = defineProps({
const { const {
currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup, currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup,
submit, submitting, submitResult, submitError, submit, submitting, submitResult, submitError,
summaryLoading, summaryAmount, summaryAmountValue, summaryLoading, summaryAmount, feeOptions, selectedFee, flowState,
} = useShortSignupForm(props.event, props.participantData) } = useShortSignupForm(props.event, props.participantData)
// Die aus dem langen Prozess übernommenen Schritte emittieren ihre Zielnummer aus jenem Flow mit. Hier zählt // 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 = [ const allSteps = [
{step: SHORT_STEP_PERSON, label: 'Person'}, {step: SHORT_STEP_PERSON, label: 'Person'},
{step: SHORT_STEP_FEE, label: 'Beitrag'},
{step: SHORT_STEP_PARTICIPATION_OPTIONS, label: 'Teilnahmeoptionen'}, {step: SHORT_STEP_PARTICIPATION_OPTIONS, label: 'Teilnahmeoptionen'},
{step: SHORT_STEP_PHOTO, label: 'Fotoerlaubnis'}, {step: SHORT_STEP_PHOTO, label: 'Fotoerlaubnis'},
{step: SHORT_STEP_PAYMENT, label: 'Zahlungsart'}, {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. // Nur die tatsächlich vorkommenden Schritte anzeigen, sonst zeigt die Leiste Schritte, die nie erscheinen.
const steps = computed(() => 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)) 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" :localGroups="localGroups" :needsLocalGroup="needsLocalGroup"
@next="onNext" @next="onNext"
/> />
<ShortStepFee
v-if="currentStep === SHORT_STEP_FEE"
:formData="formData" :feeOptions="feeOptions"
@next="onNext" @back="onBack"
/>
<StepParticipationOptions <StepParticipationOptions
v-if="currentStep === SHORT_STEP_PARTICIPATION_OPTIONS" v-if="currentStep === SHORT_STEP_PARTICIPATION_OPTIONS"
:formData="formData" :event="event" :formData="formData" :event="event"
@@ -122,7 +130,7 @@ const currentIndex = computed(() => steps.value.findIndex(s => s.step === curren
:event="event" :event="event"
:isMinor="isMinor" :isMinor="isMinor"
:summaryAmount="summaryAmount" :summaryAmount="summaryAmount"
:summaryAmountValue="summaryAmountValue" :feeLabel="feeOptions.length > 1 ? selectedFee?.label : null"
:summaryLoading="summaryLoading" :summaryLoading="summaryLoading"
:submitting="submitting" :submitting="submitting"
:submitError="submitError" :submitError="submitError"
@@ -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 // 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). // Zahlungsart gibt (die wird dann vorausgewählt).
export const SHORT_STEP_PERSON = 1 export const SHORT_STEP_PERSON = 1
export const SHORT_STEP_PARTICIPATION_OPTIONS = 2 export const SHORT_STEP_FEE = 2
export const SHORT_STEP_PHOTO = 3 export const SHORT_STEP_PARTICIPATION_OPTIONS = 3
export const SHORT_STEP_PAYMENT = 4 export const SHORT_STEP_PHOTO = 4
export const SHORT_STEP_SUMMARY = 5 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) { if (step === SHORT_STEP_PARTICIPATION_OPTIONS) {
return (event.participationOptions?.length ?? 0) > 0 return (event.participationOptions?.length ?? 0) > 0
} }
if (step === SHORT_STEP_PAYMENT) { if (step === SHORT_STEP_PAYMENT) {
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active) const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
return amountValue > 0 && activeMethods.length > 1 return state.amountValue > 0 && activeMethods.length > 1
} }
return true return true
} }
export function nextVisibleFrom(event, step, amountValue) { export function nextVisibleFrom(event, step, state) {
let candidate = step + 1 let candidate = step + 1
while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, amountValue)) { while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, state)) {
candidate++ candidate++
} }
return candidate return candidate
} }
export function prevVisibleFrom(event, step, amountValue) { export function prevVisibleFrom(event, step, state) {
let candidate = step - 1 let candidate = step - 1
while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, amountValue)) { while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, state)) {
candidate-- candidate--
} }
return candidate return candidate
@@ -1,4 +1,4 @@
import {computed, reactive, ref} from 'vue' import {computed, reactive, ref, watch} from 'vue'
import axios from 'axios' import axios from 'axios'
import {differenceInYears, parseISO} from 'date-fns' import {differenceInYears, parseISO} from 'date-fns'
import {SHORT_STEP_PERSON, nextVisibleFrom, prevVisibleFrom} from './shortStepFlow.js' import {SHORT_STEP_PERSON, nextVisibleFrom, prevVisibleFrom} from './shortStepFlow.js'
@@ -33,6 +33,8 @@ export function useShortSignupForm(event, participantData) {
intolerances: participantData.intolerances ?? '', intolerances: participantData.intolerances ?? '',
first_aid: '-1', first_aid: '-1',
badeerlaubnis: '-1', badeerlaubnis: '-1',
// Beitragsstufe; wählbar nur, wenn weitere Stufen hinterlegt sind.
feeType: 'standard',
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value } // Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
participationOptions: {}, participationOptions: {},
foto: {socialmedia: false, print: false, webseite: false, partner: false, intern: false}, 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 return differenceInYears(new Date(), parseISO(formData.geburtsdatum)) < 18
}) })
const summaryAmount = ref('') // Beitragsstufen mit fertig berechnetem Betrag: [{ value, label, amount, amountValue }]
const summaryAmountValue = ref(0) const feeOptions = ref([])
const amountLoaded = ref(false) const amountLoaded = ref(false)
// Der Betrag steht für die ganze Anmeldung fest (feste Teilnahmegruppe, fester Zeitraum) und wird deshalb const selectedFee = computed(() =>
// genau einmal geholt. Er entscheidet, ob der Zahlungsschritt überhaupt vorkommt. 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 () => { const ensureAmount = async () => {
if (amountLoaded.value) { if (amountLoaded.value) {
return return
@@ -67,31 +75,41 @@ export function useShortSignupForm(event, participantData) {
summaryLoading.value = true summaryLoading.value = true
try { try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount') const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount')
summaryAmount.value = res.data.amount feeOptions.value = res.data.feeTypes ?? []
summaryAmountValue.value = Number(res.data.amountValue ?? 0)
amountLoaded.value = true amountLoaded.value = true
if (summaryAmountValue.value <= 0) {
formData.paymentMethod = null
formData.paymentOptions = {}
}
} finally { } finally {
summaryLoading.value = false 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) => { const goToStep = (step) => {
currentStep.value = step currentStep.value = step
} }
const goNext = async () => { const goNext = async () => {
await ensureAmount() await ensureAmount()
goToStep(nextVisibleFrom(event, currentStep.value, summaryAmountValue.value)) goToStep(nextVisibleFrom(event, currentStep.value, flowState.value))
} }
const goBack = async () => { const goBack = async () => {
await ensureAmount() await ensureAmount()
goToStep(prevVisibleFrom(event, currentStep.value, summaryAmountValue.value)) goToStep(prevVisibleFrom(event, currentStep.value, flowState.value))
} }
const submit = async () => { const submit = async () => {
@@ -120,6 +138,7 @@ export function useShortSignupForm(event, participantData) {
participationOptions: formData.participationOptions, participationOptions: formData.participationOptions,
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
paymentOptions: formData.paymentOptions, paymentOptions: formData.paymentOptions,
feeType: formData.feeType,
}) })
submitResult.value = { submitResult.value = {
@@ -150,6 +169,9 @@ export function useShortSignupForm(event, participantData) {
summaryLoading, summaryLoading,
summaryAmount, summaryAmount,
summaryAmountValue, summaryAmountValue,
feeOptions,
selectedFee,
flowState,
ensureAmount, ensureAmount,
} }
} }
@@ -0,0 +1,33 @@
<script setup>
// Erscheint nur, wenn neben dem Standardbeitrag weitere Stufen hinterlegt sind. Die Beträge gelten für die gesamte
// Veranstaltung -- die Kurzanmeldung hat keinen wählbaren Zeitraum.
defineProps({
formData: Object,
feeOptions: {type: Array, default: () => []},
})
const emit = defineEmits(['next', 'back'])
</script>
<template>
<div>
<h3>Teilnahmebeitrag</h3>
<p style="margin: 0 0 16px 0; color: #6b7280; font-size: 0.95rem;">
Bitte wähle deinen Beitrag für die gesamte Veranstaltung.
</p>
<label
v-for="option in feeOptions"
:key="option.value"
style="display: block; margin-bottom: 8px; cursor: pointer;"
>
<input type="radio" v-model="formData.feeType" :value="option.value" />
{{ option.label }}
<span style="color: #606060;">({{ option.amount }})</span>
</label>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back')">← Zurück</button>
<button type="button" class="btn-primary" @click="emit('next')">Weiter →</button>
</div>
</div>
</template>
@@ -8,7 +8,8 @@ const props = defineProps({
event: Object, event: Object,
isMinor: Boolean, isMinor: Boolean,
summaryAmount: String, summaryAmount: String,
summaryAmountValue: {type: Number, default: 0}, // Nur gesetzt, wenn zwischen mehreren Beitragsstufen gewählt werden konnte.
feeLabel: {type: String, default: null},
summaryLoading: Boolean, summaryLoading: Boolean,
submitting: Boolean, submitting: Boolean,
submitError: {type: String, default: ''}, submitError: {type: String, default: ''},
@@ -137,6 +138,10 @@ const canSubmit = computed(() =>
<h4 style="margin: 0 0 8px 0;">Kostenübersicht</h4> <h4 style="margin: 0 0 8px 0;">Kostenübersicht</h4>
<table class="form-table cost-table" style="margin-bottom: 20px;"> <table class="form-table cost-table" style="margin-bottom: 20px;">
<tr v-if="feeLabel">
<td>Beitragsstufe:</td>
<td>{{ feeLabel }}</td>
</tr>
<tr class="cost-total"> <tr class="cost-total">
<td><strong>Teilnahmebeitrag:</strong></td> <td><strong>Teilnahmebeitrag:</strong></td>
<td><strong>{{ summaryAmount }}</strong></td> <td><strong>{{ summaryAmount }}</strong></td>
@@ -26,6 +26,18 @@ class SaveInvoiceController extends CommonController
]); ]);
} }
// Sicherheitsnetz hinter der Oberfläche: Wer gereist ist, steht im Zahlungsgrund auf Beleg und EüR und
// ist bei Fahrtkosten Pflicht. Geprüft wird hier und nicht im Command, weil auch Abrechnungskorrekturen
// durch den Command laufen -- sie bringen ihren Zahlungsgrund mit, aber keine Personen. Und vor dem
// Upload, damit eine abgelehnte Einreichung keinen verwaisten Beleg hinterlässt.
if ($invoiceType === InvoiceType::INVOICE_TYPE_TRAVELLING
&& Validator::make($request->all(), ['travellers' => 'required|string'])->fails()) {
return response()->json([
'status' => 'error',
'message' => 'Bitte gib an, welche Personen gereist sind.'
]);
}
$uploadedFile = null; $uploadedFile = null;
if (null !== $request->file('receipt')) { if (null !== $request->file('receipt')) {
$maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16); $maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16);
@@ -41,10 +41,11 @@ const travelReasonComplete = computed(() =>
/** /**
* Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer * Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer
* den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login * den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login
* bleibt das Feld leer, und es hält den Ablauf auch dann nicht auf: Ein Name ist eine Hilfe, keine * bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund.
* Bedingung.
*/ */
const travellers = ref(data.userName || ''); const travellers = ref(data.userName || '');
const travellersComplete = computed(() => travellers.value.trim() !== '');
const have_receipt = ref('') const have_receipt = ref('')
const amount = ref(0.00); const amount = ref(0.00);
const invoiceType = ref(null); const invoiceType = ref(null);
@@ -121,7 +122,7 @@ function handleFileChange(event) {
/> />
</fieldset><br /><br /> </fieldset><br /><br />
<fieldset v-if="travelReasonComplete"> <fieldset v-if="travelReasonComplete && travellersComplete">
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend> <legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" /> <input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" /> <input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
@@ -129,7 +130,8 @@ function handleFileChange(event) {
<br /><br /> <br /><br />
<fieldset v-if="have_receipt === 'yes'"> <!-- Auch nach "Ja"/"Nein" gilt die Pflicht: Wer die Personen wieder löscht, kann nicht einreichen. -->
<fieldset v-if="travellersComplete && have_receipt === 'yes'">
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend> <legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro <AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
@@ -161,7 +163,7 @@ function handleFileChange(event) {
/> />
</fieldset> </fieldset>
<fieldset v-else-if="distanceAllowance != null"> <fieldset v-else-if="travellersComplete && distanceAllowance != null">
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend> <legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
Gesamtlänge des Reisewegs: Gesamtlänge des Reisewegs:
<NumericInput <NumericInput
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/** /**
* @property string $name * @property string $name
* @property string $type * @property string $type
* @property string $billing_deadline * @property string|null $billing_deadline Y-m-d, Abrechnungsschluss einschließlich des ganzen Tages
* @property string $distance_allowance * @property string $distance_allowance
* @property boolean $mail_on_new * @property boolean $mail_on_new
* @property boolean $allow_new * @property boolean $allow_new
+9 -2
View File
@@ -234,13 +234,20 @@ class Event extends InstancedModel
* jede Anmeldung dieser Gruppe zu. Null, wenn für die Veranstaltung noch keine Gebühr hinterlegt ist -- * jede Anmeldung dieser Gruppe zu. Null, wenn für die Veranstaltung noch keine Gebühr hinterlegt ist --
* dann ist eine Kurzanmeldung nicht möglich, weil `participation_type` am Teilnehmer Pflicht ist. * dann ist eine Kurzanmeldung nicht möglich, weil `participation_type` am Teilnehmer Pflicht ist.
*/ */
public function firstParticipationTypeSlug() : ?string { /**
* Die erste hinterlegte Teilnahmegruppe -- die Kurzanmeldung meldet immer in dieser an.
*/
public function firstParticipationFee() : ?EventParticipationFee {
return collect([ return collect([
$this->participationFee1, $this->participationFee1,
$this->participationFee2, $this->participationFee2,
$this->participationFee3, $this->participationFee3,
$this->participationFee4, $this->participationFee4,
])->first(fn ($participationFee) => $participationFee !== null)?->type; ])->first(fn ($participationFee) => $participationFee !== null);
}
public function firstParticipationTypeSlug() : ?string {
return $this->firstParticipationFee()?->type;
} }
} }
@@ -21,6 +21,27 @@ class EventParticipationFee extends CommonModel
]; ];
/** Beitragsstufen in Anzeigereihenfolge; die Schlüssel sind die Spaltensuffixe von `amount_*`. */
public const array FEE_TYPE_LABELS = [
'standard' => 'Standardbeitrag',
'reduced' => 'Reduzierter Beitrag',
'solidarity' => 'Solidaritätsbeitrag',
];
/**
* Die Beitragsstufen, die für diese Teilnahmegruppe tatsächlich hinterlegt sind. Den Standardbeitrag gibt es
* immer, reduzierter und Solidaritätsbeitrag sind optional. Nur für diese Stufen liefert
* `EventResource::calculateAmount()` einen Betrag.
*
* @return array<int, string>
*/
public function availableFeeTypes() : array {
return array_values(array_filter(
array_keys(self::FEE_TYPE_LABELS),
fn (string $feeType) => $this->{'amount_' . $feeType} !== null
));
}
public function tenant() : BelongsTo { public function tenant() : BelongsTo {
return $this->belongsTo(Tenant::class, 'tenant', 'slug'); return $this->belongsTo(Tenant::class, 'tenant', 'slug');
} }
+1
View File
@@ -49,6 +49,7 @@ class CostUnitResource {
$data = array_merge( $data = array_merge(
$this->costUnit->toArray(), $this->costUnit->toArray(),
[ [
'billingDeadline' => $this->costUnit->billing_deadline,
'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(), 'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(),
'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(), 'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(),
'totalAmount' => new Amount($totalAmount, ' Euro')->toString(), 'totalAmount' => new Amount($totalAmount, ' Euro')->toString(),
+2 -2
View File
@@ -239,8 +239,8 @@ class EventResource extends JsonResource{
} }
public function getMultiplier() : float { public function getMultiplier() : float {
$earlyBirdEnd = $this->event->early_bird_end; // Der Frühbucherpreis gilt einschließlich des ganzen Stichtags.
if ($earlyBirdEnd > now()) { if (now() <= $this->event->early_bird_end->copy()->endOfDay()) {
return 1; return 1;
} }
+6 -9
View File
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail;
class CloseCostUnit implements CronTask { class CloseCostUnit implements CronTask {
public function handle(): void public function handle(): void
{ {
$now = now();
$costUnitRepository = new CostUnitRepository(); $costUnitRepository = new CostUnitRepository();
$now = now(); $now = now();
$billingResetEnd = clone $now; $billingResetEnd = clone $now;
@@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask {
$billingEnd = $costUnit['billing_deadline']; $billingEnd = $costUnit['billing_deadline'];
if ($billingEnd === null) { if ($billingEnd === null) {
$billingEnd = \DateTime::createFromFormat('Y-m-d H:i:s', $billingResetEnd)->format('Y-m-d H:i:s'); $billingEnd = $billingResetEnd->format('Y-m-d');
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest( new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
CostUnit::where('id', $costUnit['id'])->first(), CostUnit::where('id', $costUnit['id'])->first(),
Amount::fromString($costUnit['distance_allowance']), Amount::fromString($costUnit['distance_allowance']),
@@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask {
))->execute(); ))->execute();
} }
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd); $billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd);
if (false === $billingEndTime) { if (false === $billingEndTime) {
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd); $billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
} }
if (false === $billingEndTime) { if (false === $billingEndTime) {
@@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask {
continue; continue;
} }
$billingEndTime->setTime(0,0,0); // Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden.
$billingEndTime->add(new \DateInterval('P1D')); $billingEndTime->setTime(23, 59, 59);
if ($now > $billingEndTime) {
if ($billingEndTime < $now) {
new ChangeCostUnitStateCommand( new ChangeCostUnitStateCommand(
new ChangeCostUnitStateRequest( new ChangeCostUnitStateRequest(
CostUnit::where('id', $costUnit['id'])->first(),false, false CostUnit::where('id', $costUnit['id'])->first(),false, false
+2 -1
View File
@@ -12,7 +12,8 @@ class CloseEvent implements CronTask {
$eventRepository = new EventRepository(); $eventRepository = new EventRepository();
/** @var Event $event */ /** @var Event $event */
foreach ($eventRepository->getAvailable(false) as $event) { foreach ($eventRepository->getAvailable(false) as $event) {
if ($event->registration_final_end <= $now ) { // Der Anmeldeschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf man sich anmelden.
if ($now > $event->registration_final_end->copy()->endOfDay()) {
$event->registration_allowed = false; $event->registration_allowed = false;
$event->save(); $event->save();
} }
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Abrechnungsschluss ist ein Tag, keine Uhrzeit: eingereicht werden darf bis einschließlich 23:59 des Stichtags.
* Die Uhrzeit, die bisher mitgespeichert wurde, hat der Cron ohnehin verworfen -- das Abschneiden ändert kein
* Verhalten. Erst normalisieren, dann den Typ ändern, damit das ALTER nichts abschneiden muss.
*/
return new class extends Migration {
public function up(): void
{
DB::table('cost_units')
->whereNotNull('billing_deadline')
->update(['billing_deadline' => DB::raw('DATE(billing_deadline)')]);
Schema::table('cost_units', function (Blueprint $table) {
$table->date('billing_deadline')->nullable()->change();
});
}
public function down(): void
{
Schema::table('cost_units', function (Blueprint $table) {
$table->dateTime('billing_deadline')->nullable()->change();
});
}
};
+104
View File
@@ -0,0 +1,104 @@
# Produktions-Image für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
# Unabhängig von docker/prod.Dockerfile, das der bestehende Server weiter nutzt.
#
# Targets:
# app – PHP-FPM mit Code, vendor/ und gebauten Assets
# web – nginx, liefert public/ aus und reicht PHP an "app" weiter
# ---------------------------------------------------------------------------
# base: PHP 8.5 mit den Extensions, die mareike braucht
# ---------------------------------------------------------------------------
FROM php:8.5-fpm-alpine AS base
ARG UID=2000
ARG GID=2000
RUN apk add --no-cache \
imagemagick \
libpng \
libxml2 \
libzip \
oniguruma \
&& apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
imagemagick-dev \
libpng-dev \
libxml2-dev \
libzip-dev \
oniguruma-dev \
&& pecl install imagick \
&& docker-php-ext-enable imagick \
&& docker-php-ext-install mysqli pdo_mysql mbstring zip exif pcntl gd \
&& apk del .build-deps \
&& rm -rf /tmp/pear
# Nutzer mit der UID/GID des Host-Nutzers, damit das eingebundene storage/ beschreibbar ist.
# Existiert die GID im Image schon, wird die vorhandene Gruppe verwendet.
RUN if getent group "${GID}" > /dev/null; then \
GROUP_NAME="$(getent group "${GID}" | cut -d: -f1)"; \
else \
addgroup -S -g "${GID}" mareike && GROUP_NAME=mareike; \
fi \
&& adduser -S -D -H -u "${UID}" -G "${GROUP_NAME}" mareike
COPY --chmod=0755 docker/php/composer.phar /usr/bin/composer
COPY docker/endeavour/php.ini /usr/local/etc/php/conf.d/zz-mareike.ini
WORKDIR /var/www/html
# ---------------------------------------------------------------------------
# vendor: Composer-Abhängigkeiten (ohne Dev-Pakete)
# ---------------------------------------------------------------------------
FROM base AS vendor
RUN apk add --no-cache git unzip
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress
# ---------------------------------------------------------------------------
# assets: Vite-Build (Vite importiert aus app/, daher der volle Quellcode)
# ---------------------------------------------------------------------------
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# ---------------------------------------------------------------------------
# app: PHP-FPM
# ---------------------------------------------------------------------------
FROM base AS app
COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Der Code gehört root; beschreibbar sind nur storage/ und bootstrap/cache/.
RUN composer dump-autoload --optimize --no-dev --no-interaction \
&& mkdir -p \
storage/app/private \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/temp \
&& chown -R mareike: storage bootstrap/cache
USER mareike
EXPOSE 9000
CMD ["php-fpm"]
# ---------------------------------------------------------------------------
# web: nginx vor PHP-FPM
# ---------------------------------------------------------------------------
FROM nginx:stable-alpine AS web
COPY docker/endeavour/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=app /var/www/html/public /var/www/html/public
EXPOSE 80
+36
View File
@@ -0,0 +1,36 @@
# Build-Kontext für docker/endeavour/Dockerfile.
# BuildKit nutzt diese Datei anstelle der .dockerignore im Projektroot, die für den bestehenden Server
# unverändert bleibt. Wichtig: keine .env ins Image – sie wird zur Laufzeit eingebunden.
.git
.ai
.junie
.claude
.idea
.fleet
.vscode
.zed
.env
.env.*
node_modules
vendor
public/build
public/hot
public/storage
storage/*
bootstrap/cache/*.php
tests
.phpunit.cache
.phpunit.result.cache
docker/ssl
docker/logs
docker-compose.yaml
_ide_helper.php
_ide_helper_models.php
gitlog.log
+42
View File
@@ -0,0 +1,42 @@
# Compose-Stack für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
# Wird über /opt/mareike/bin/deploy.sh gesteuert, das die MAREIKE_*-Variablen setzt.
name: mareike
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
app:
build:
context: ../..
dockerfile: docker/endeavour/Dockerfile
target: app
args:
UID: ${MAREIKE_UID:-2000}
GID: ${MAREIKE_GID:-2000}
image: mareike-endeavour-app:latest
restart: unless-stopped
volumes:
- ${MAREIKE_ENV_FILE:-/opt/mareike/shared/.env}:/var/www/html/.env:ro
- ${MAREIKE_STORAGE:-/opt/mareike/storage}:/var/www/html/storage
# Socket der KeyHelp-MariaDB auf dem Host (DB_SOCKET=/run/mysqld/mysqld.sock)
- ${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}:/run/mysqld
logging: *logging
web:
build:
context: ../..
dockerfile: docker/endeavour/Dockerfile
target: web
image: mareike-endeavour-web:latest
restart: unless-stopped
depends_on:
- app
ports:
# Nur lokal erreichbar – nach außen geht es ausschließlich über den KeyHelp-Webserver.
- "127.0.0.1:${MAREIKE_HTTP_PORT:-8080}:80"
logging: *logging
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Deployment von mareike auf endeavour (KeyHelp-Server), siehe docs/implement-endeavour.md.
#
# Liegt auf dem Server als KOPIE unter /opt/mareike/bin/deploy.sh – nicht direkt aus src/ ausführen,
# weil der git-Checkout sonst das laufende Skript austauscht.
#
# Aufruf (als root):
# deploy.sh [REF] Tag, Branch oder Commit ausrollen (Default: main)
# deploy.sh artisan … php artisan im laufenden app-Container
# deploy.sh compose … docker compose mit der endeavour-Konfiguration (z. B. "compose logs -f")
set -euo pipefail
MAREIKE_BASE="${MAREIKE_BASE:-/opt/mareike}"
MAREIKE_USER="${MAREIKE_USER:-mareike-docker}"
SRC="$MAREIKE_BASE/src"
export MAREIKE_ENV_FILE="$MAREIKE_BASE/shared/.env"
export MAREIKE_STORAGE="$MAREIKE_BASE/storage"
export MAREIKE_HTTP_PORT="${MAREIKE_HTTP_PORT:-8080}"
export MAREIKE_DB_SOCKET_DIR="${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}"
MAREIKE_UID="$(id -u "$MAREIKE_USER")"
MAREIKE_GID="$(id -g "$MAREIKE_USER")"
export MAREIKE_UID MAREIKE_GID
compose() {
docker compose -f "$SRC/docker/endeavour/compose.yaml" "$@"
}
case "${1:-}" in
artisan)
shift
compose exec app php artisan "$@"
exit
;;
compose)
shift
compose "$@"
exit
;;
esac
REF="${1:-main}"
if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
echo "Fehlt: $MAREIKE_ENV_FILE (Vorlage: docker/endeavour/env.production.example)" >&2
exit 1
fi
echo "==> Quellcode auf '$REF' bringen"
git -C "$SRC" fetch --tags --prune --force origin
if git -C "$SRC" show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git -C "$SRC" checkout --force -B "$REF" "origin/$REF"
else
git -C "$SRC" checkout --force --detach "$REF"
fi
git -C "$SRC" clean -fd
echo "==> storage/ vorbereiten"
for dir in \
"" \
app app/private app/public \
framework framework/cache framework/cache/data framework/sessions framework/views \
logs temp
do
install -d -m 0775 -o "$MAREIKE_USER" -g "$MAREIKE_GID" "$MAREIKE_STORAGE/$dir"
done
echo "==> Images bauen"
compose build --pull
echo "==> Datenbank migrieren"
compose run --rm --no-deps app php artisan migrate --force
echo "==> Container starten"
compose up -d --remove-orphans
compose exec -T app php artisan view:clear
docker image prune -f > /dev/null
echo "==> mareike $(cat "$SRC/version") ($(git -C "$SRC" rev-parse --short HEAD)) läuft."
+65
View File
@@ -0,0 +1,65 @@
# Vorlage für /opt/mareike/shared/.env auf endeavour.
# Nach dem Kopieren alle <...>-Platzhalter ersetzen. Diese Datei kommt NIE ins Image und NIE ins Repo.
# Keine ${...}-Verweise auf andere Variablen verwenden, Werte ausschreiben.
APP_NAME=mareike
APP_ENV=production
APP_DEBUG=false
# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
APP_KEY=
# Domain des Dach-Tenants "lv"
APP_URL=https://<lv-subdomain>.meine-anmeldung.digital
# Kein ASSET_URL: Assets kommen von der jeweiligen Tenant-Domain selbst.
APP_LOCALE=de
APP_FALLBACK_LOCALE=de
LOG_CHANNEL=stack
LOG_STACK=daily
LOG_DAILY_DAYS=30
LOG_LEVEL=warning
# KeyHelp-MariaDB auf dem Host, per Unix-Socket (Verzeichnis /run/mysqld ist in den Container gemountet)
DB_CONNECTION=mysql
DB_HOST=localhost
DB_SOCKET=/run/mysqld/mysqld.sock
DB_DATABASE=<datenbankname-aus-keyhelp>
DB_USERNAME=<datenbanknutzer-aus-keyhelp>
DB_PASSWORD=<passwort>
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=true
SESSION_DOMAIN=null
CACHE_STORE=database
QUEUE_CONNECTION=sync
FILESYSTEM_DISK=local
# Versand über ein KeyHelp-Postfach (STARTTLS auf 587)
MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=<hostname-des-mailservers>
MAIL_PORT=587
MAIL_USERNAME=<noreply@meine-anmeldung.digital>
MAIL_PASSWORD=<passwort>
MAIL_FROM_ADDRESS=<noreply@meine-anmeldung.digital>
MAIL_FROM_NAME=mareike
# Empfänger der Admin-Benachrichtigungen (z. B. neue Registrierungen)
APP_ADMIN_MAIL=<admin@meine-anmeldung.digital>
APP_ADMIN_NAME="<Name>"
# Maximale Größe hochgeladener Belege in MB (php.ini erlaubt bis 64)
MAX_INVOICE_FILE_SIZE=16
# WebDAV-Ablage: unbedingt ein EIGENER Ordner, getrennt vom bestehenden Server
WEBDAV_HOST=<https://cloud.example.org/remote.php/dav/files/<nutzer>/<eigener-ordner>/>
WEBDAV_USER=<nutzer>
WEBDAV_PASS=<app-passwort>
# Prüfdienst für erweiterte Führungszeugnisse
COC_CHECK_URL=<https://efzcheck.example.org/api/v1/check-efz>
# MUSS leer bleiben: "bdp-lv-sachsen" würde beim Seeden die Sachsen-Tenants und deren Personen einspielen.
PROVIDER=
+54
View File
@@ -0,0 +1,54 @@
# nginx im Container "web" auf endeavour.
# TLS terminiert der KeyHelp-Webserver auf dem Host und reicht per Proxy an 127.0.0.1:8080 weiter.
# Echte Client-IP aus X-Forwarded-For übernehmen, aber nur von Docker-internen Adressen
# (der Proxy auf dem Host erreicht den Container über das Bridge-Gateway).
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 80 default_server;
server_name _;
root /var/www/html/public;
index index.php;
server_tokens off;
client_max_body_size 70m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Vite-Assets sind versioniert (Hash im Dateinamen) und dürfen lange gecacht werden.
location /build/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Nach außen läuft alles über HTTPS (KeyHelp). So erzeugt Laravel https-URLs,
# ohne dass im App-Code trustProxies gesetzt werden muss.
fastcgi_param HTTPS on;
fastcgi_param REQUEST_SCHEME https;
fastcgi_param SERVER_PORT 443;
fastcgi_read_timeout 300;
}
location ~ /\.(?!well-known) {
deny all;
}
}
+20
View File
@@ -0,0 +1,20 @@
; PHP-Einstellungen für endeavour (Container "app").
; Uploads: Obergrenze für MAX_INVOICE_FILE_SIZE (MB, Default 16) in der .env, POST etwas größer für die
; Formulardaten.
upload_max_filesize = 64M
post_max_size = 70M
memory_limit = 512M
max_execution_time = 120
expose_php = Off
date.timezone = Europe/Berlin
; OPcache ist ab PHP 8.5 fest eingebaut. Zeitstempel werden weiter geprüft, weil Blade kompilierte
; Views in storage/ unter gleichem Dateinamen neu schreibt.
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2
+410
View File
@@ -0,0 +1,410 @@
# mareike auf endeavour ausrollen (KeyHelp-Server mit Docker)
Diese Anleitung beschreibt, wie mareike **zusätzlich** zum bestehenden Server auf *endeavour* läuft. Endeavour ist ein
Server, der mit KeyHelp verwaltet wird. Die Parent-Domain ist `meine-anmeldung.digital`.
KeyHelp selbst nutzt kein Docker. mareike läuft deshalb in zwei Containern **neben** KeyHelp. KeyHelp übernimmt
weiter, was es gut kann: Domains, Let's-Encrypt-Zertifikate, MariaDB, Postfächer und Backups.
> **Der bestehende Server bleibt unberührt.** Für endeavour gibt es eigene Dateien unter `docker/endeavour/`.
> `docker-compose.prod`, `docker/prod.Dockerfile`, `docker/run-mareike.sh`, `docker/nginx/default.conf` und
> `.dockerignore` werden nicht verändert und von endeavour nicht benutzt.
---
## 1. Überblick
```
Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, eine Domain je Tenant)
│ Proxy, Host-Header bleibt erhalten
▼
127.0.0.1:8080 ──▶ Container "web" (nginx, liefert public/ aus)
│ FastCGI
▼
Container "app" (PHP 8.5-FPM, mareike)
│ Unix-Socket /run/mysqld/mysqld.sock
▼
KeyHelp-MariaDB (Host)
```
- **Tenant-Erkennung:** `IdentifyTenant` sucht den Tenant über `tenants.url` = Host der Anfrage. Deshalb muss der
Proxy den Host-Header unverändert weitergeben, und jede Tenant-Domain braucht exakt diesen Eintrag in `tenants.url`.
- **HTTPS:** TLS endet bei KeyHelp. Die nginx im Container meldet PHP trotzdem `HTTPS=on`, damit Laravel `https://`-Links
erzeugt. Eine Code-Änderung ist dafür nicht nötig.
- **Port 8080** ist nur an `127.0.0.1` gebunden und von außen nicht erreichbar.
### Dateien im Repo
| Datei | Zweck |
|---|---|
| `docker/endeavour/Dockerfile` | Multi-Stage-Build. Target `app` enthält PHP-FPM, Code, vendor/ und Assets, Target `web` enthält nginx und public/. |
| `docker/endeavour/Dockerfile.dockerignore` | Eigener Build-Kontext. Unter anderem kommt **keine `.env` ins Image**. |
| `docker/endeavour/compose.yaml` | Stack mit den Services `app` und `web`. |
| `docker/endeavour/nginx.conf` | nginx im Container, inkl. `HTTPS on` und echter Client-IP. |
| `docker/endeavour/php.ini` | Upload-Größen, Speicher, OPcache. |
| `docker/endeavour/env.production.example` | Vorlage für die `.env` auf dem Server. |
| `docker/endeavour/deploy.sh` | Deploy-Skript: pullen, bauen, migrieren, starten. |
### Verzeichnisse auf endeavour
```
/opt/mareike/
├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
├── shared/.env Konfiguration mit Zugangsdaten (nur root lesbar)
└── storage/ Laravel-storage/ (Logs, Sessions, erzeugte PDFs/ZIPs) – persistent
```
---
## 2. Docker installieren
Als root auf endeavour. Das Beispiel gilt für Debian, bei Ubuntu steht in den Pfaden `ubuntu` statt `debian`.
```bash
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
Log-Rotation für alle Container (`/etc/docker/daemon.json`):
```json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "5" }
}
```
Docker soll erst **nach** MariaDB starten, weil der Container das Socket-Verzeichnis `/run/mysqld` einbindet:
```bash
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/after-mariadb.conf <<'EOF'
[Unit]
After=mariadb.service
Wants=mariadb.service
EOF
systemctl daemon-reload
systemctl restart docker
docker run --rm hello-world
```
> **KeyHelp-Firewall:** Docker legt eigene iptables-Regeln an. Lädt KeyHelp seine Firewall neu, können diese Regeln
> verloren gehen. Die Container sind dann nicht mehr erreichbar (502 im Browser). Abhilfe ist
> `systemctl restart docker`. Weil mareike nur an `127.0.0.1` lauscht, öffnet Docker nach außen keine Ports.
---
## 3. Systemnutzer, Verzeichnisse, Repository
Die Container laufen mit der UID eines eigenen Host-Nutzers, damit `storage/` sauber beschreibbar ist. Der Name weicht
bewusst von den KeyHelp-Konten ab, weil KeyHelp für seine Konten selbst Systemnutzer anlegt.
```bash
getent passwd 2000 || useradd --system --uid 2000 --user-group --no-create-home --shell /usr/sbin/nologin mareike-docker
# Ist UID 2000 belegt, eine andere freie UID wählen. deploy.sh liest sie automatisch aus.
install -d -m 0755 /opt/mareike /opt/mareike/bin
install -d -m 0700 /opt/mareike/shared
install -d -m 0775 -o mareike-docker -g mareike-docker /opt/mareike/storage
```
Deploy-Key für das Repository anlegen (nur Lesezugriff):
```bash
ssh-keygen -t ed25519 -f /root/.ssh/mareike_deploy -N "" -C "endeavour-deploy"
cat /root/.ssh/mareike_deploy.pub # in Gitea beim Repo th.guenther/mareike als Deploy-Key (read-only) eintragen
cat >> /root/.ssh/config <<'EOF'
Host git.zahlenlabyrinth.de
Port 9922
User git
IdentityFile /root/.ssh/mareike_deploy
IdentitiesOnly yes
EOF
git clone ssh://git@git.zahlenlabyrinth.de:9922/th.guenther/mareike.git /opt/mareike/src
install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/deploy.sh
```
> Ändert sich `docker/endeavour/deploy.sh` im Repo, danach den `install`-Befehl erneut ausführen.
---
## 4. Datenbank in KeyHelp anlegen
1. Im KeyHelp-Panel ein Konto für mareike anlegen oder ein vorhandenes wählen, z. B. `anmeldung`. Unter diesem Konto
liegen später auch die Domains.
2. Unter **Datenbanken** eine neue MariaDB-Datenbank mit Benutzer anlegen. Der Benutzer ist `@localhost`. Den
Remote-Zugriff **nicht** aktivieren, denn der Container verbindet sich über den Unix-Socket und gilt damit als
`localhost`.
3. Den Socket-Pfad prüfen:
```bash
mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
```
Liegt der Socket woanders, in `/opt/mareike/bin/deploy.sh` die Variable `MAREIKE_DB_SOCKET_DIR` auf das Verzeichnis
setzen und `DB_SOCKET` in der `.env` anpassen.
Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-Backup** enthalten.
---
## 5. `.env` anlegen
```bash
install -m 0600 /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
echo "base64:$(openssl rand -base64 32)" # Ergebnis als APP_KEY eintragen
nano /opt/mareike/shared/.env
```
Die wichtigsten Punkte:
- `APP_KEY` wird einmal erzeugt und dann **nie wieder geändert**, weil Laravel damit verschlüsselte Daten sonst nicht
mehr lesen kann.
- `APP_URL` ist die Domain des Dach-Tenants `lv` (siehe Abschnitt 6).
- `DB_*` sind die Daten aus Schritt 4. `DB_HOST=localhost` und `DB_SOCKET` bleiben wie in der Vorlage.
- `MAIL_*`: Am einfachsten ist ein Postfach in KeyHelp, etwa `noreply@meine-anmeldung.digital`. Als Host den
Mail-Hostnamen des Servers eintragen, mit Port 587.
- `WEBDAV_*`: **eigener Zielordner**, damit sich endeavour und der bestehende Server nicht gegenseitig Dateien
überschreiben.
- `PROVIDER` **muss leer bleiben.** Mit `bdp-lv-sachsen` würde `db:seed` die Sachsen-Tenants samt Personendaten
einspielen.
- `ASSET_URL` nicht setzen, dann kommen die Assets von der jeweiligen Tenant-Domain.
- In der Datei keine `${VAR}`-Verweise verwenden, sondern alle Werte ausschreiben.
---
## 6. Erstinstallation
### 6.1 Erstes Deployment
```bash
/opt/mareike/bin/deploy.sh main # oder einen Release-Tag, z. B. 4.9.1
```
Das Skript holt den Code, baut beide Images, legt die Tabellen per `migrate` an und startet die Container. Der erste
Build dauert ein paar Minuten.
Kurztest:
```bash
/opt/mareike/bin/deploy.sh compose ps
curl -sI http://127.0.0.1:8080/ | head -1 # 404 ist hier richtig: es gibt noch keinen Tenant
```
### 6.2 Stammdaten einspielen
```bash
/opt/mareike/bin/deploy.sh artisan db:seed --force
```
Mit leerem `PROVIDER` läuft nur der `ProductionDataSeeder`. Er legt Rollen, Status, Zahlungsarten, Kostenstellentypen
und die Befreiungsgründe an. Den Befehl **nur einmal** ausführen, ein zweiter Lauf würde Duplikate anlegen.
### 6.3 Dach-Tenant `lv` und Cron-Aufgaben anlegen
Der Slug `lv` ist im Code fest als Dach-Ebene verdrahtet (`LvOnlyMiddleware`, Registrierung, Tenant-Verwaltung).
endeavour braucht deshalb genau einen Tenant mit diesem Slug. Seine `url` ist die Domain, die in Abschnitt 7 in KeyHelp
angelegt wird.
```bash
/opt/mareike/bin/deploy.sh artisan tinker
```
```php
App\Models\Tenant::create([
'slug' => 'lv',
'name' => '<Name des Verbands>',
'url' => '<lv-subdomain>.meine-anmeldung.digital',
'email' => '<kontakt@…>',
'email_finance' => '<finanzen@…>',
'account_name' => '<Kontoinhaber>',
'account_iban' => '<IBAN>',
'account_bic' => '<BIC>',
'city' => '<Ort>',
'postcode' => '<PLZ>',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
use App\Enumerations\CronTaskType;
App\Models\CronTask::create(['name' => 'UploadInvoices', 'execution_type' => CronTaskType::CRON_TASK_TYPE_REALTIME]);
App\Models\CronTask::create(['name' => 'CloseCostUnit', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:05']);
App\Models\CronTask::create(['name' => 'CloseEvent', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:10']);
App\Models\CronTask::create(['name' => 'NotifyTeam', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '20:00']);
```
Die Cron-Aufgaben entsprechen denen des bestehenden Servers (`BdpLvSachsenDataSeeder::installCronTasks()`). Sie gelten
global und werden für jeden aktiven Tenant ausgeführt.
### 6.4 Erste Administratorin / ersten Administrator
1. Auf `https://<lv-subdomain>.meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
erledigt und der Mailversand funktioniert.
2. Das Konto zur Administration hochstufen:
```bash
mysql <datenbankname> -e "UPDATE users SET user_role_main='ROLE_ADMINISTRATOR', user_role_local_group='ROLE_GROUP_LEADER', active=1 WHERE email='<adresse>';"
```
---
## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
Diesen Ablauf für den Dach-Tenant `lv` und später für jede weitere Gruppe wiederholen.
### 7.1 Einmalig: Proxy-Module aktivieren (Apache)
```bash
a2enmod proxy proxy_http headers
systemctl reload apache2
```
### 7.2 Domain anlegen
1. KeyHelp → **Domains** → neue Domain für das mareike-Konto, z. B. `wilde-moehre.meine-anmeldung.digital`.
Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht selbst verwaltet.
2. **SSL/TLS:** Let's Encrypt aktivieren und **HTTPS erzwingen** einschalten.
3. PHP wird für diese Domain nicht gebraucht.
4. In den Domain-Einstellungen im Feld für **individuelle Webserver-Direktiven** (nur als Admin sichtbar, die
Bezeichnung variiert je nach KeyHelp-Version) Folgendes eintragen:
**Apache** (KeyHelp-Standard):
```apache
ProxyPreserveHost On
ProxyRequests Off
RequestHeader set X-Forwarded-Proto "https"
ProxyPass /.well-known/acme-challenge/ !
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
```
**nginx** (falls KeyHelp auf nginx läuft):
```nginx
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 70m;
}
```
Die Ausnahme für `/.well-known/acme-challenge/` sorgt dafür, dass KeyHelp die Zertifikate weiter selbst verlängern
kann.
5. Speichern und die Domain aufrufen. Solange der Tenant noch nicht existiert, zeigt mareike **404 „Tenant not
found“**. Das ist ein gutes Zeichen, denn der Proxy funktioniert.
### 7.3 Tenant in mareike anlegen
Im Dach-Tenant `lv` unter der Tenant-Verwaltung den neuen Tenant anlegen. Als URL **exakt** die Domain ohne `https://`
und ohne Schrägstrich eintragen, z. B. `wilde-moehre.meine-anmeldung.digital`.
Für den Tenant `lv` selbst ist das bereits in Schritt 6.3 passiert.
---
## 8. Cron einrichten
mareike hat keinen Laravel-Scheduler. Die Aufgaben laufen über die URL `/execute-crons`, die für **alle** aktiven
Tenants arbeitet. Sie muss einmal pro Minute aufgerufen werden, dabei genügt der Host-Header eines beliebigen aktiven
Tenants:
```bash
cat > /etc/cron.d/mareike <<'EOF'
* * * * * root curl -fsS -o /dev/null -H "Host: <lv-subdomain>.meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
EOF
```
Kontrolle: `/opt/mareike/storage/logs/` enthält die Task-Logs.
---
## 9. Updates ausrollen
```bash
/opt/mareike/bin/deploy.sh 4.9.2 # Release-Tag
/opt/mareike/bin/deploy.sh main # oder Branch
```
Das Skript läuft in dieser Reihenfolge:
1. `git fetch` und Checkout des Standes.
2. `storage/`-Ordner prüfen.
3. Images neu bauen, dabei werden Basis-Images mit Sicherheitsupdates frisch gezogen.
4. `migrate --force` ausführen.
5. Container neu starten.
6. Kompilierte Views leeren.
7. Alte Images aufräumen.
Nützliche Kurzbefehle:
```bash
/opt/mareike/bin/deploy.sh compose ps
/opt/mareike/bin/deploy.sh compose logs -f web
/opt/mareike/bin/deploy.sh artisan migrate:status
/opt/mareike/bin/deploy.sh artisan tinker
```
**Rollback:** Den vorherigen Tag ausrollen (`deploy.sh 4.9.1`). Migrationen werden dabei **nicht** zurückgedreht.
Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
---
## 10. Backup
| Was | Wie |
|---|---|
| Datenbank | Über das KeyHelp-Backup, weil die DB von KeyHelp verwaltet wird. |
| `/opt/mareike/storage` | **Nicht** im KeyHelp-Backup. Zusätzlich sichern, z. B. per borg/restic oder rsync auf das Backup-Ziel. |
| `/opt/mareike/shared/.env` | Ebenfalls zusätzlich sichern, besonders wegen des `APP_KEY`. |
| Code und Images | Nicht nötig, lassen sich jederzeit aus dem Repo neu bauen. |
---
## 11. Fehlersuche
| Symptom | Ursache / Lösung |
|---|---|
| 404 „Tenant not found“ | Der Host passt nicht zu `tenants.url` (Tippfehler, `https://` oder `/` im Eintrag), der Tenant ist nicht `has_active_instance`, oder der Proxy reicht den Host nicht durch (`ProxyPreserveHost On` fehlt). |
| 502/503 vom KeyHelp-Webserver | Container laufen nicht (`deploy.sh compose ps`) oder die Firewall wurde neu geladen, dann hilft `systemctl restart docker`. |
| Seite lädt ohne CSS, Mixed Content | `ASSET_URL` ist gesetzt oder die nginx-Config im Container wurde nicht übernommen (`fastcgi_param HTTPS on`). |
| `SQLSTATE[HY000] [2002]` | Socket nicht erreichbar. `SELECT @@socket` prüfen, `MAREIKE_DB_SOCKET_DIR` bzw. `DB_SOCKET` anpassen, nach einem MariaDB-Neustart den Stack mit `deploy.sh compose restart app` neu starten. |
| `Permission denied` in storage/ | Besitzer von `/opt/mareike/storage` muss `mareike-docker` sein, der nächste `deploy.sh`-Lauf korrigiert das. |
| Upload bricht ab | Grenzen prüfen: `MAX_INVOICE_FILE_SIZE` in der `.env`, 64 MB in `php.ini`, 70 MB in `nginx.conf`. |
| Mails kommen nicht an | `MAIL_*` prüfen, Log in `storage/logs/`. Absenderadresse muss zum Postfach passen (SPF/DKIM der Domain in KeyHelp). |
Logs:
```bash
/opt/mareike/bin/deploy.sh compose logs --tail=200 app web
ls -lt /opt/mareike/storage/logs/
```
---
## 12. Abgrenzung zum bestehenden Server
| | bestehender Server | endeavour |
|---|---|---|
| Dockerfile | `docker/prod.Dockerfile` | `docker/endeavour/Dockerfile` |
| Compose | `docker-compose.prod` | `docker/endeavour/compose.yaml` |
| nginx | `docker/nginx/default.conf` | `docker/endeavour/nginx.conf` |
| Build-Kontext | `.dockerignore` | `docker/endeavour/Dockerfile.dockerignore` |
| Datenbank | eigene | KeyHelp-MariaDB auf endeavour |
| Tenants | `*.mareike.sachsen.pfadfinden.de` | `*.meine-anmeldung.digital` |
Beide Server nutzen denselben Code-Stand aus demselben Repository, haben aber **getrennte** Datenbanken, `.env`,
WebDAV-Ordner und Tenants. Änderungen an `docker/endeavour/` wirken nur auf endeavour.
@@ -0,0 +1,184 @@
<?php
namespace Tests\Feature;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsCommand;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Tenant;
use App\Models\User;
use App\Tasks\CloseCostUnit;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Der Abrechnungsschluss einer Kostenstelle.
*
* Er ist ein Tag, keine Uhrzeit: Abrechnungen dürfen bis einschließlich 23:59 des Stichtags eingereicht werden. Das
* Modal „Details anpassen" muss ihn anzeigen -- sonst löscht jedes Speichern den Wert unbemerkt.
*/
class CostUnitBillingDeadlineTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
}
public function test_details_liefern_den_abrechnungsschluss_im_format_des_datumsfelds(): void
{
$costUnit = $this->makeCostUnit('2026-09-11');
$this->actingAs($this->makeAdmin());
$response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details');
$response->assertOk();
$this->assertSame('2026-09-11', $response->json('costUnit.billingDeadline'));
}
public function test_details_ohne_abrechnungsschluss_liefern_null(): void
{
$costUnit = $this->makeCostUnit(null);
$this->actingAs($this->makeAdmin());
$response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details');
$this->assertNull($response->json('costUnit.billingDeadline'));
}
public function test_speichern_uebernimmt_das_datum_aus_dem_formular(): void
{
$costUnit = $this->makeCostUnit('2026-09-11');
$this->actingAs($this->makeAdmin());
$this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [
'billingDeadline' => '2026-09-30',
'mailOnNew' => true,
'distanceAllowance' => '0,30',
]])->assertOk()->assertJson(['status' => 'success']);
$this->assertSame('2026-09-30', $costUnit->fresh()->billing_deadline);
}
public function test_ein_geleertes_datumsfeld_entfernt_den_abrechnungsschluss_ohne_fehler(): void
{
$costUnit = $this->makeCostUnit('2026-09-11');
$this->actingAs($this->makeAdmin());
$this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [
'billingDeadline' => '',
'mailOnNew' => false,
'distanceAllowance' => '0,25',
]])->assertOk()->assertJson(['status' => 'success']);
$this->assertNull($costUnit->fresh()->billing_deadline);
}
public function test_eine_uhrzeit_im_request_wird_nicht_gespeichert(): void
{
$costUnit = $this->makeCostUnit(null);
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
$costUnit,
Amount::fromString('0,25'),
false,
new DateTime('2026-09-11 14:23:45'),
))->execute();
$this->assertSame('2026-09-11', $costUnit->fresh()->billing_deadline);
}
public function test_am_stichtag_um_2359_ist_die_kostenstelle_noch_offen(): void
{
$costUnit = $this->makeCostUnit('2026-09-11');
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
new CloseCostUnit()->handle();
$this->assertTrue((bool) $costUnit->fresh()->allow_new);
}
public function test_am_folgetag_schliesst_der_cron_die_kostenstelle(): void
{
$costUnit = $this->makeCostUnit('2026-09-11');
$this->travelTo(new DateTime('2026-09-12 00:05:00'));
new CloseCostUnit()->handle();
$this->assertFalse((bool) $costUnit->fresh()->allow_new);
}
public function test_ohne_abrechnungsschluss_setzt_der_cron_sechs_wochen_und_laesst_offen(): void
{
$costUnit = $this->makeCostUnit(null);
$this->travelTo(new DateTime('2026-09-12 00:05:00'));
new CloseCostUnit()->handle();
$costUnit = $costUnit->fresh();
$this->assertSame('2026-10-24', $costUnit->billing_deadline);
$this->assertTrue((bool) $costUnit->allow_new);
}
private function makeCostUnit(?string $billingDeadline): CostUnit
{
return CostUnit::create([
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'billing_deadline' => $billingDeadline,
'distance_allowance' => 0.25,
'mail_on_new' => false,
'allow_new' => true,
'archived' => false,
]);
}
private function makeAdmin(): User
{
return User::create([
'username' => 'admin-' . uniqid() . '@example.com',
'email' => 'admin-' . uniqid() . '@example.com',
'firstname' => 'Test',
'lastname' => 'Person',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_ADMIN,
'active' => true,
]);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Models\Event;
use App\Models\Tenant;
use App\Resources\EventResource;
use App\Tasks\CloseEvent;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Anmeldeschluss und Early-Bird-Ende einer Veranstaltung.
*
* Beide sind ein Tag, keine Uhrzeit: Sie gelten einschließlich des ganzen Stichtags bis 23:59.
*/
class EventDeadlineTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'tv',
'name' => 'Test',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => 'test.local',
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
}
public function test_am_anmeldeschluss_um_2359_bleibt_die_anmeldung_offen(): void
{
$event = $this->makeEvent();
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
new CloseEvent()->handle();
$this->assertTrue($event->fresh()->registration_allowed);
}
public function test_am_folgetag_schliesst_der_cron_die_anmeldung(): void
{
$event = $this->makeEvent();
$this->travelTo(new DateTime('2026-09-12 00:10:00'));
new CloseEvent()->handle();
$this->assertFalse($event->fresh()->registration_allowed);
}
public function test_am_early_bird_stichtag_um_2359_gilt_der_fruehbucherpreis(): void
{
$event = $this->makeEvent();
$this->travelTo(new DateTime('2026-09-11 23:59:00'));
$this->assertEqualsWithDelta(1.0, new EventResource($event)->getMultiplier(), 0.0001);
}
public function test_am_tag_nach_dem_early_bird_stichtag_gilt_der_aufschlag(): void
{
$event = $this->makeEvent();
$this->travelTo(new DateTime('2026-09-12 00:00:01'));
$this->assertEqualsWithDelta(1.2, new EventResource($event)->getMultiplier(), 0.0001);
}
private function makeEvent(): Event
{
return Event::create([
'tenant' => $this->tenant->slug,
'name' => 'Event',
'identifier' => 'evt-' . uniqid(),
'location' => 'Ort',
'postal_code' => '00000',
'email' => 'e@example.com',
'start_date' => '2026-09-20',
'end_date' => '2026-09-25',
'early_bird_end' => '2026-09-11',
'registration_final_end' => '2026-09-11',
'early_bird_end_amount_increase' => 20,
'registration_allowed' => true,
'account_owner' => 'Owner',
'account_iban' => 'DE00',
'participation_fee_type' => 'fixed',
'pay_per_day' => false,
'pay_direct' => false,
]);
}
}
+36 -1
View File
@@ -183,12 +183,47 @@ class InvoicePurposeTest extends TestCase
public function test_travel_costs_without_travellers_record_only_the_reason(): void public function test_travel_costs_without_travellers_record_only_the_reason(): void
{ {
// Ohne Login steht im Formular niemand drin -- das darf den Beleg nicht aufhalten. // Der Command selbst verlangt keine Personen: Abrechnungskorrekturen laufen ohne sie durch. Die
// Pflicht beim Einreichen prüft der SaveInvoiceController -- siehe die Tests unten.
$invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager'); $invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager');
$this->assertSame('Landeslager', $invoice->purpose); $this->assertSame('Landeslager', $invoice->purpose);
} }
/**
* Eine Fahrtkostenabrechnung über das Formular, als Kilometerpauschale ohne Beleg.
*/
private function postTravelExpense(CostUnit $costUnit, ?string $travellers): \Illuminate\Testing\TestResponse
{
return $this->postJson('/api/v1/invoice/new/' . $costUnit->id . '/' . InvoiceType::INVOICE_TYPE_TRAVELLING, [
'name' => 'Max Mustermann',
'amount' => '100',
'otherText' => 'Halle – Leipzig',
'decision' => 'payout',
'travelReason' => 'Landeslager',
'travellers' => $travellers,
]);
}
public function test_submitting_travel_costs_requires_who_travelled(): void
{
$costUnit = $this->makeCostUnit();
foreach ([null, '', ' '] as $travellers) {
$this->postTravelExpense($costUnit, $travellers)
->assertJson(['status' => 'error', 'message' => 'Bitte gib an, welche Personen gereist sind.']);
}
$this->assertSame(0, Invoice::count());
}
public function test_submitting_travel_costs_with_travellers_is_accepted(): void
{
$this->postTravelExpense($this->makeCostUnit(), 'Mika und Kim')->assertJson(['status' => 'success']);
$this->assertSame('Landeslager — Mika und Kim', Invoice::sole()->purpose);
}
public function test_a_refund_records_no_purpose(): void public function test_a_refund_records_no_purpose(): void
{ {
// Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie // Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie
+58 -3
View File
@@ -50,7 +50,12 @@ class ShortSignUpTest extends TestCase
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']); EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
} }
private function createEvent(bool $withFee = true, bool $swimmingPermissionRequired = true): Event private function createEvent(
bool $withFee = true,
bool $swimmingPermissionRequired = true,
?float $amountReduced = 30.0,
?float $amountSolidarity = 70.0,
): Event
{ {
$event = Event::create([ $event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort', 'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
@@ -65,7 +70,7 @@ class ShortSignUpTest extends TestCase
$fee = EventParticipationFee::create([ $fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0, 'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0,
'amount_reduced' => 30.0, 'amount_solidarity' => 70.0, 'amount_reduced' => $amountReduced, 'amount_solidarity' => $amountSolidarity,
]); ]);
$event->participation_fee_1 = $fee->id; $event->participation_fee_1 = $fee->id;
$event->save(); $event->save();
@@ -102,6 +107,7 @@ class ShortSignUpTest extends TestCase
paymentMethod: $overrides['paymentMethod'] ?? PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, paymentMethod: $overrides['paymentMethod'] ?? PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
paymentOptions: [], paymentOptions: [],
participationOptions: $overrides['participationOptions'] ?? [], participationOptions: $overrides['participationOptions'] ?? [],
feeType: $overrides['feeType'] ?? 'standard',
); );
} }
@@ -127,7 +133,7 @@ class ShortSignUpTest extends TestCase
$this->assertSame(1, $participant->arrival_eating); $this->assertSame(1, $participant->arrival_eating);
$this->assertSame(2, $participant->departure_eating); $this->assertSame(2, $participant->departure_eating);
// Gruppe und Beitragsstufe stehen fest. // Die Gruppe steht fest, ohne Wahl gilt der Standardbeitrag.
$this->assertSame(ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $participant->participation_type); $this->assertSame(ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $participant->participation_type);
$this->assertSame('standard', $participant->fee_type); $this->assertSame('standard', $participant->fee_type);
$this->assertSame(50.0, $participant->amount->getAmount()); $this->assertSame(50.0, $participant->amount->getAmount());
@@ -142,6 +148,55 @@ class ShortSignUpTest extends TestCase
$this->assertFalse($participant->foto_print); $this->assertFalse($participant->foto_print);
} }
public function test_gewaehlter_reduzierter_beitrag_wird_berechnet(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'reduced']))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('reduced', $response->participant->fee_type);
$this->assertSame(30.0, $response->participant->amount->getAmount());
}
public function test_gewaehlter_solidaritaetsbeitrag_wird_berechnet(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'solidarity']))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('solidarity', $response->participant->fee_type);
$this->assertSame(70.0, $response->participant->amount->getAmount());
}
public function test_nicht_hinterlegte_beitragsstufe_wird_abgelehnt(): void
{
$event = $this->createEvent(amountReduced: null);
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'reduced']))->execute();
$this->assertFalse($response->success);
$this->assertNotNull($response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_waehlbar_sind_nur_hinterlegte_beitragsstufen(): void
{
$this->assertSame(
['standard', 'reduced', 'solidarity'],
$this->createEvent()->firstParticipationFee()->availableFeeTypes()
);
$withoutReduced = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0,
'amount_reduced' => null, 'amount_solidarity' => 70.0,
]);
$this->assertSame(['standard', 'solidarity'], $withoutReduced->availableFeeTypes());
}
public function test_u18_ohne_abgefragte_badeerlaubnis_bekommt_keine(): void public function test_u18_ohne_abgefragte_badeerlaubnis_bekommt_keine(): void
{ {
$event = $this->createEvent(swimmingPermissionRequired: false); $event = $this->createEvent(swimmingPermissionRequired: false);
+1 -1
View File
@@ -1 +1 @@
4.8.1 4.9.2