13 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
th.guenther b668c7e805 Bugfix für KSV-Listen 2026-09-10 11:56:24 +02:00
th.guenther ddfaab1501 Anschrift für Veranstaltungsort 2026-09-10 11:45:52 +02:00
th.guenther 70a57d10f5 Kurzanmeldungen 2026-09-10 11:11:20 +02:00
th.guenther 6301c342e4 Automatischer Zahlungsparser 2026-09-10 09:50:14 +02:00
th.guenther 025035190d User-Comfort im Zahlungsparser 2026-09-09 17:32:50 +02:00
th.guenther 651b6147bf Zahlungsparser 2026-09-09 16:55:08 +02:00
th.guenther ae13841699 Merge pull request 'Dev 4.8.1' (#16) from dev-4.8.1 into main
Reviewed-on: #16
2026-09-07 14:18:56 +02:00
124 changed files with 8719 additions and 285 deletions

No files matched your search

@@ -5,6 +5,7 @@ namespace App\Domains\Admin\Controllers;
use App\Models\AvailablePaymentMethod;
use App\Resources\AvailablePaymentMethodResource;
use App\Scopes\CommonController;
use App\ValueObjects\BankStatementRuleset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -16,6 +17,10 @@ class TenantPaymentMethodsGetController extends CommonController
'paymentMethods' => AvailablePaymentMethod::all()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods',
// Der app-weite Standard für das Kontoauszug-Format. Das Formular zeigt ihn als
// Vorbelegung an, damit erkennbar ist, wovon ein Override abweicht.
'statementRulesetDefault' => BankStatementRuleset::default()->toArray(),
'statementRulesetCharsets' => BankStatementRuleset::CHARSETS,
]);
}
}
@@ -1,5 +1,6 @@
<script setup>
import {computed, ref} from 'vue';
import BankRulesetEditor from "../../../../Views/Components/BankRulesetEditor.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
@@ -102,6 +103,10 @@ async function save() {
<RichSelectBox v-if="option.type === 'icon'" v-model="form.configuration[option.name]"
:options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/>
<BankRulesetEditor v-else-if="option.type === 'bank-ruleset'"
v-model="form.configuration[option.name]"
:defaults="data.statementRulesetDefault ?? {}"
:charsets="data.statementRulesetCharsets ?? undefined"/>
<input v-else type="text" v-model="form.configuration[option.name]" class="form-input"/>
<span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td>
@@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand {
$this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount();
$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();
return $response;
@@ -17,7 +17,7 @@ class CreateCostUnitCommand {
'name' => $this->request->name,
'tenant' => currentTenant()->slug,
'type' => $this->request->type,
'billing_deadline' => $this->request->billingDeadline,
'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'),
'distance_allowance' => $this->request->distanceAllowance->getAmount(),
'mail_on_new' => $this->request->mailOnNew,
'allow_new' => true,
@@ -37,7 +37,10 @@ class EditController extends CommonController{
$saveParams = $request->get('formData');
$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);
$command = new ChangeCostUnitDetailsCommand($request);
@@ -33,7 +33,7 @@ function navigateTo(url) {
</tr>
<tr>
<td>
{{participation.event.postal_code}} {{participation.event.location}}<br />
{{participation.eventAddress}}<br />
</td>
<td>
<a class="link" :href="`/api/v1/event/participant/${participation.identifier}/ical`">In Kalender importieren</a>
@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Event\Actions\BookBankStatementPayments;
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentCommand;
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentRequest;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Repositories\EventParticipantRepository;
use App\ValueObjects\Amount;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
/**
* Bucht die in der Prüfansicht bestätigten Zahlungseingänge -- alle in einem Durchgang.
*
* Bewusst **ohne** umspannende Transaktion: Gebucht wird über {@see ParticipantPaymentCommand}, und der
* verschickt die Zahlungsmail gleich mit. Das Projekt kennt keine Queue, die Mails gehen also synchron
* raus; eine offene Transaktion über achtzig Mailversände wäre die schlechtere Wahl. Jede Zeile ist
* für sich vollständig, und das Wasserzeichen schützt sie gegen eine Wiederholung.
*/
class BookBankStatementPaymentsCommand
{
public function __construct(private readonly BookBankStatementPaymentsRequest $request)
{
}
public function execute(): BookBankStatementPaymentsResponse
{
$response = new BookBankStatementPaymentsResponse();
if ($this->request->bookings === []) {
$response->message = 'Es wurden keine Zahlungen zum Buchen ausgewählt.';
return $response;
}
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
$participants = new EventParticipantRepository();
foreach ($this->request->bookings as $booking) {
$participant = $participants->findInEventByIdentifier(
$this->request->event,
(string) ($booking['participantIdentifier'] ?? ''),
);
$transaction = $this->toTransaction($booking);
if ($participant === null || $transaction === null) {
$response->failed++;
continue;
}
// Zweite Prüfung, nicht bloß Gürtel und Hosenträger: In der Prüfansicht lässt sich eine
// Zeile von Hand einer anderen Person zuordnen, und deren Wasserzeichen hat der erste
// Durchlauf nie gesehen.
if ($this->isBeforeWatermark($participant, $transaction)) {
$response->skipped++;
continue;
}
if ($module instanceof ReadsBankStatements) {
$module->recordTransaction($participant, $transaction);
}
$participant->last_payment_date = $transaction->paymentDate;
$this->book($participant, $transaction->amount);
$response->booked++;
}
$response->success = true;
$response->message = $this->summaryMessage($response);
return $response;
}
/**
* Bucht den Betrag **zusätzlich** zum bereits Gezahlten.
*
* Die manuelle Erfassung setzt `amount_paid` absolut, weil dort jemand den Gesamtstand eintippt.
* Hier kommt eine einzelne Zahlung an -- eine zweite Rate darf die erste nicht überschreiben.
* Der {@see ParticipantPaymentCommand} speichert dabei die zuvor gesetzten Felder mit und schickt
* die passende Mail (bezahlt / fehlt noch / überzahlt).
*/
private function book(EventParticipant $participant, Amount $amount): void
{
$total = new Amount(
round(($participant->amount_paid?->getAmount() ?? 0.0) + $amount->getAmount(), 2),
'Euro',
);
new ParticipantPaymentCommand(new ParticipantPaymentRequest($participant, $total))->execute();
}
/** @param array<string, mixed> $booking */
private function toTransaction(array $booking): ?BankTransaction
{
$date = CarbonImmutable::createFromFormat('!Y-m-d', (string) ($booking['paymentDate'] ?? ''));
$amount = $booking['amount'] ?? null;
if ($date === false || !is_numeric($amount) || (float) $amount <= 0) {
return null;
}
return new BankTransaction(
paymentDate: $date,
amount: new Amount(round((float) $amount, 2), 'Euro'),
purpose: (string) ($booking['purpose'] ?? ''),
payerName: (string) ($booking['payerName'] ?? ''),
payerIban: (string) ($booking['payerIban'] ?? ''),
rowNumber: (int) ($booking['rowNumber'] ?? 0),
);
}
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
{
$watermark = $participant->last_payment_date;
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
}
private function summaryMessage(BookBankStatementPaymentsResponse $response): string
{
$message = sprintf('%d Zahlungen wurden gebucht.', $response->booked);
if ($response->skipped > 0) {
$message .= sprintf(
' %d wurden übersprungen, weil sie älter sind als die zuletzt erfasste Zahlung der Person.',
$response->skipped,
);
}
if ($response->failed > 0) {
$message .= sprintf(' %d konnten nicht zugeordnet werden.', $response->failed);
}
return $message;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Event\Actions\BookBankStatementPayments;
use App\Models\Event;
class BookBankStatementPaymentsRequest
{
/**
* @param array<int, array<string, mixed>> $bookings Je Eintrag die bestätigte Zeile der Prüfansicht:
* participantIdentifier, paymentDate, amount,
* payerName, payerIban, purpose.
*/
public function __construct(
public readonly Event $event,
public readonly array $bookings,
) {
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Event\Actions\BookBankStatementPayments;
class BookBankStatementPaymentsResponse
{
public bool $success = false;
public string $message = '';
public int $booked = 0;
/** Übersprungen, weil älter als die zuletzt erfasste Zahlung der Person. */
public int $skipped = 0;
/** Nicht verarbeitbar (unbekannte Anmeldung, unbrauchbarer Betrag). */
public int $failed = 0;
}
@@ -12,7 +12,8 @@ class CertificateOfConductionCheckCommand {
public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse();
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()->name);
// Der Stamm ist optional (die Kurzanmeldung erhebt ihn nicht, in der Verwaltung kann er entfernt werden).
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()?->name ?? '');
$apiResponse = Http::acceptJson()
->asJson()
@@ -38,6 +38,8 @@ class CreateEventCommand {
'name' => $this->request->name,
'identifier' => Str::random(10),
'location' => $this->request->location,
'street' => $this->normalizeOptional($this->request->street),
'house_number' => $this->normalizeOptional($this->request->houseNumber),
'postal_code' => $this->request->postalCode,
'email' => $this->request->email,
'start_date' => $this->request->begin,
@@ -107,6 +109,17 @@ class CreateEventCommand {
return $response;
}
/**
* Straße und Hausnummer sind optional. Ein leer gelassenes Eingabefeld liefert einen leeren String --
* gespeichert wird dafür `null`, damit „nicht angegeben" nur eine Darstellung hat.
*/
private function normalizeOptional(?string $value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $value;
}
/**
* Event-Teil der Rechnungsnummer, z.B. `WM-V-20260701`: Tenant-Präfix, Dokumentart „V" für
* Veranstaltung, dann ohne Trenner Jahr, Monat des Beginns und die laufende Nummer der
@@ -19,8 +19,10 @@ class CreateEventRequest {
public string $accountOwner;
public string $accountIban;
public bool $payPerDay;
public ?string $street;
public ?string $houseNumber;
public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay) {
public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay, ?string $street = null, ?string $houseNumber = null) {
$this->name = $name;
$this->location = $location;
$this->postalCode = $postalCode;
@@ -34,5 +36,7 @@ class CreateEventRequest {
$this->accountOwner = $accountOwner;
$this->accountIban = $accountIban;
$this->payPerDay = $payPerDay;
$this->street = $street;
$this->houseNumber = $houseNumber;
}
}
@@ -18,7 +18,7 @@ class GenerateIcalCommand
$dtEnd = $event->end_date->copy()->addDay()->format('Ymd');
$now = now()->format('Ymd\THis\Z');
$summary = $this->escapeIcal($event->name);
$location = $this->escapeIcal(trim($event->postal_code . ' ' . $event->location));
$location = $this->escapeIcal($event->getFullAddress());
$description = $this->escapeIcal('Teilnahme als: ' . $participant->getOfficialName());
$icalContent = implode("\r\n", [
@@ -0,0 +1,183 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\BankStatementParseProvider;
use App\Repositories\EventParticipantRepository;
use App\Support\BankStatementParseException;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
/**
* Liest einen hochgeladenen Kontoauszug und schlägt je Zahlungseingang eine Anmeldung vor.
*
* Hier wird nichts gebucht -- das Ergebnis ist die Prüfansicht, in der die Aktionsleitung die
* Vorschläge bestätigt, korrigiert oder verwirft. Erst der zweite Schritt schreibt.
*
* Der Ablauf ist zahlartneutral: Was ein verwertbarer Umsatz ist und zu wem er gehört, entscheidet
* das Zahlungsmodul über {@see ReadsBankStatements}.
*/
class ParseBankStatementCommand
{
/** 8 MB -- ein Jahresauszug bleibt weit darunter, alles darüber ist die falsche Datei. */
private const int MAX_BYTES = 8 * 1024 * 1024;
private const array ALLOWED_EXTENSIONS = ['csv', 'txt'];
public function __construct(private readonly ParseBankStatementRequest $request)
{
}
public function execute(): ParseBankStatementResponse
{
$response = new ParseBankStatementResponse();
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
if (!$module instanceof ReadsBankStatements) {
$response->message = 'Für die Überweisung ist kein Kontoauszug-Import eingerichtet.';
return $response;
}
$contents = $this->readFile($response);
if ($contents === null) {
return $response;
}
$ruleset = $module instanceof ProvidesStatementRuleset
? $module->statementRuleset($this->request->configuration)
: BankStatementRuleset::default();
try {
$transactions = new BankStatementParseProvider()->parse($contents, $ruleset);
} catch (BankStatementParseException $exception) {
$response->message = $exception->getMessage();
return $response;
}
$candidates = new EventParticipantRepository()->getForPaymentMatching($this->request->event);
foreach ($transactions as $transaction) {
if (!$module->isRelevantTransaction($transaction)) {
continue;
}
$match = $module->matchTransaction($transaction, $candidates);
// Vor der letzten erfassten Zahlung dieser Person: schon gebucht, oder aus einem Auszug,
// der bereits verarbeitet wurde. Gar nicht erst anzeigen.
if ($match !== null && $this->isBeforeWatermark($match->participant, $transaction)) {
$response->skippedOlderThanWatermark++;
continue;
}
$response->rows[] = $transaction->toArray() + [
'suggestedIdentifier' => $match?->participant->identifier,
'confidence' => $match?->confidence,
];
}
$response->participants = $candidates
->map(fn (EventParticipant $participant): array => $this->participantOption($participant))
->values()
->all();
$response->success = true;
$response->message = $this->summaryMessage($response);
return $response;
}
private function readFile(ParseBankStatementResponse $response): ?string
{
$file = $this->request->file;
if ($file === null || !$file->isValid()) {
$response->message = 'Es wurde keine Datei hochgeladen.';
return null;
}
if ($file->getSize() > self::MAX_BYTES) {
$response->message = 'Die Datei ist zu groß (maximal 8 MB).';
return null;
}
if (!in_array(strtolower($file->getClientOriginalExtension()), self::ALLOWED_EXTENSIONS, true)) {
$response->message = 'Bitte den CSV-Export der Bank hochladen (.csv).';
return null;
}
$contents = file_get_contents($file->getRealPath());
if ($contents === false || trim($contents) === '') {
$response->message = 'Die Datei ließ sich nicht lesen oder ist leer.';
return null;
}
return $contents;
}
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
{
$watermark = $participant->last_payment_date;
// Echt kleiner, nicht kleiner-gleich: Zwei Zahlungen am selben Tag sollen beide ankommen.
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
}
/** @return array<string, mixed> */
private function participantOption(EventParticipant $participant): array
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
return [
'identifier' => $participant->identifier,
'name' => $participant->lastname . ', ' . $participant->firstname,
'amount' => $participant->amount?->toString(),
'amountPaid' => $participant->amount_paid?->toString(),
'amountOpen' => $amountLeft->toString(),
'isSettled' => round($amountLeft->getAmount(), 2) <= 0,
'lastPaymentDate' => $participant->last_payment_date?->format('d.m.Y'),
// Zahlt jemand, der sich abgemeldet hat, wird die Zahlung trotzdem erfasst -- danach
// steht aber eine Erstattung an. Die Prüfansicht weist darauf hin.
'isSignedOff' => $participant->unregistered_at !== null,
'signedOffAt' => $participant->unregistered_at?->format('d.m.Y'),
];
}
private function summaryMessage(ParseBankStatementResponse $response): string
{
if ($response->rows === []) {
return $response->skippedOlderThanWatermark > 0
? 'Alle Zahlungseingänge dieser Datei wurden bereits erfasst.'
: 'In der Datei sind keine Zahlungseingänge zu dieser Aktion enthalten.';
}
$assigned = count(array_filter($response->rows, static fn (array $row): bool => $row['suggestedIdentifier'] !== null));
$message = sprintf(
'%d Zahlungseingänge gelesen, %d davon konnten zugeordnet werden.',
count($response->rows),
$assigned,
);
if ($response->skippedOlderThanWatermark > 0) {
$message .= sprintf(' %d bereits erfasste Zahlungen wurden übersprungen.', $response->skippedOlderThanWatermark);
}
return $message;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
use App\Models\Event;
use Illuminate\Http\UploadedFile;
class ParseBankStatementRequest
{
/**
* @param array<string, mixed> $configuration Tenant-Konfiguration der Überweisung. Wird hereingereicht
* statt hier geholt -- den DB-Zugriff macht der Controller
* über das Repository.
*/
public function __construct(
public readonly Event $event,
public readonly ?UploadedFile $file,
public readonly array $configuration = [],
) {
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
class ParseBankStatementResponse
{
public bool $success = false;
public string $message = '';
/**
* Eine Zeile je verwertbarem Umsatz, fertig für die Prüfansicht.
*
* @var array<int, array<string, mixed>>
*/
public array $rows = [];
/**
* Alle zuordenbaren Anmeldungen der Aktion für die Auswahlliste.
*
* @var array<int, array<string, mixed>>
*/
public array $participants = [];
/**
* Umsätze, die vor der zuletzt erfassten Zahlung der erkannten Person liegen. Sie erscheinen gar
* nicht erst in der Prüfansicht -- gezählt werden sie trotzdem, sonst bliebe unerklärt, warum
* eine Datei mit 40 Zeilen nur 12 Vorschläge ergibt.
*/
public int $skippedOlderThanWatermark = 0;
}
@@ -64,7 +64,7 @@ class SetPaymentMethodsCommand
// Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
if ($override !== null) {
$row = $existing[$slug];
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override);
$row->configuration = $this->eventConfiguration($slug, $override);
$row->save();
}
continue;
@@ -75,8 +75,27 @@ class SetPaymentMethodsCommand
EventPaymentMethods::create([
'event_id' => $event->id,
'slug' => $slug,
'configuration' => PaymentMethod::sanitizeConfiguration($slug, $snapshot ?? []),
'configuration' => $this->eventConfiguration($slug, $snapshot ?? []),
]);
}
}
/**
* Was von einer Konfiguration am Event gespeichert wird.
*
* Neben dem Zuschnitt aufs Schema fallen hier die tenant-weiten Optionen heraus. Das
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden -- eingefroren
* ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren. IBAN und
* Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
*
* @param array<string, mixed> $configuration
* @return array<string, mixed>
*/
private function eventConfiguration(string $slug, array $configuration): array
{
return PaymentMethod::stripTenantScopedOptions(
$slug,
PaymentMethod::sanitizeConfiguration($slug, $configuration),
);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\ValueObjects\Age;
/**
* Verkürzte Anmeldung: ergänzt die nicht erhobenen Pflichtangaben um Platzhalter und übergibt an die reguläre
* Anmelde-Action. Dadurch gelten für Kurzanmeldungen dieselben Regeln wie für jede andere Anmeldung
* (Transaktion, laufende Rechnungsnummer, eFZ-Vorbelegung, Snapshot der Teilnahmeoptionen).
*/
class ShortSignUpCommand {
/** Platzhalter für Angaben, die die Kurzanmeldung nicht erhebt. */
private const string PLACEHOLDER = 'Nicht erforderlich';
/**
* Die Postleitzahl bekommt bewusst keinen Text-Platzhalter: aus ihr wird in Teilnehmerliste und CSV-Export
* das Bundesland abgeleitet.
*/
private const string PLACEHOLDER_POSTCODE = '00000';
public function __construct(public ShortSignUpRequest $request) {
}
public function execute() : ShortSignUpResponse {
$response = new ShortSignUpResponse();
// Ohne konfigurierte Teilnahmegruppe gäbe es keinen gültigen `participation_type` -- sauber abbrechen,
// statt in einen Fremdschlüssel-Fehler zu laufen.
$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
// darauf nicht.
if (!$participantAge->isfullAged()
&& ($this->request->contactPerson === null || $this->request->contactEmail === null)) {
$response->message = 'Für Minderjährige werden Name und E-Mail-Adresse einer Kontaktperson benötigt.';
return $response;
}
// Stamm: bei genau einem teilnehmenden Stamm wird dieser gesetzt, bei mehreren muss gewählt werden,
// ohne teilnehmende Stämme bleibt die Zuordnung leer.
$localGroups = $this->request->event->localGroups;
$localGroup = null;
if ($localGroups->count() === 1) {
$localGroup = $localGroups->first();
} elseif ($localGroups->count() > 1) {
$localGroup = $localGroups->firstWhere('id', $this->request->localGroupId);
if ($localGroup === null) {
$response->message = 'Bitte einen Stamm auswählen.';
return $response;
}
}
$eventResource = $this->request->event->toResource();
// Zeitraum und Gruppe stehen bei der Kurzanmeldung fest, wählbar ist nur die Beitragsstufe.
$amount = $eventResource->calculateAmount(
$participationType,
$this->request->feeType,
$this->request->event->start_date,
$this->request->event->end_date,
false
);
$signUpRequest = new SignUpRequest(
event: $this->request->event,
user_id: $this->request->user_id,
firstname: $this->request->firstname,
lastname: $this->request->lastname,
nickname: $this->request->nickname,
participationType: $participationType,
localGroup: $localGroup,
birthday: $this->request->birthday,
address_1: self::PLACEHOLDER,
address_2: null,
postcode: self::PLACEHOLDER_POSTCODE,
city: self::PLACEHOLDER,
email_1: $this->request->email,
phone_1: $this->request->phone,
email_2: $this->request->contactEmail,
phone_2: null,
contact_person: $this->request->contactPerson,
allergies: $this->request->allergies,
intolerances: $this->request->intolerances,
medications: null,
tetanus_vaccination: null,
// Kein Wert der Zuordnung -- der SignUpCommand fällt damit auf "Omnivor" zurück.
eating_habit: '',
swimming_permission: $this->resolveSwimmingPermission($participantAge),
first_aid_permission: $participantAge->isfullAged() ? '-1' : ($this->request->firstAidPermission ?? '-1'),
foto_socialmedia: $this->request->foto_socialmedia,
foto_print: $this->request->foto_print,
foto_webseite: $this->request->foto_webseite,
foto_partner: $this->request->foto_partner,
foto_intern: $this->request->foto_intern,
arrival: $this->request->event->start_date,
departure: $this->request->event->end_date,
arrival_eating: 1,
departure_eating: 2,
notes: null,
amount: $amount,
paymentMethod: $this->request->paymentMethod,
paymentOptions: $this->request->paymentOptions,
participationOptions: $this->request->participationOptions,
addonItems: [],
feeType: $this->request->feeType,
siblingReduction: false,
);
$signUpResponse = new SignUpCommand($signUpRequest)->execute();
$response->success = $signUpResponse->success;
$response->participant = $signUpResponse->participant;
$response->message = $signUpResponse->message;
return $response;
}
/**
* Bei Volljährigen und wenn die Veranstaltung die Badeerlaubnis nicht abfragt, entscheidet der SignUpCommand
* (volljährig => erteilt, nicht abgefragt => keine). Nur die getroffene Auswahl Minderjähriger wird
* durchgereicht.
*/
private function resolveSwimmingPermission(Age $participantAge) : ?string
{
if ($participantAge->isfullAged() || !$this->request->event->swimming_permission_required) {
return null;
}
return $this->request->swimmingPermission;
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Models\Event;
use DateTime;
/**
* Eingabedaten der verkürzten Anmeldung -- ausschließlich das, was der Kurz-Wizard tatsächlich erhebt.
* Alle übrigen Pflichtangaben des Teilnehmers setzt der Command auf Platzhalter.
*/
class ShortSignUpRequest {
function __construct(
public Event $event,
public ?int $user_id,
public string $firstname,
public string $lastname,
public ?string $nickname,
public DateTime $birthday,
public string $email,
public string $phone,
/**
* Tenant-ID des Stamms. Nur gefüllt, wenn die Veranstaltung mehrere teilnehmende Stämme hat --
* bei genau einem wird dieser gesetzt, ohne zu fragen.
*/
public ?int $localGroupId,
/** Kontaktperson: nur bei Minderjährigen erhoben. */
public ?string $contactPerson,
public ?string $contactEmail,
public ?string $allergies,
public ?string $intolerances,
/** Slug aus `first_aid_permissions`; nur bei Minderjährigen erhoben. */
public ?string $firstAidPermission,
/** Slug aus `swimming_permissions`; nur bei Minderjährigen und nur wenn die Veranstaltung sie abfragt. */
public ?string $swimmingPermission,
public bool $foto_socialmedia,
public bool $foto_print,
public bool $foto_webseite,
public bool $foto_partner,
public bool $foto_intern,
public ?string $paymentMethod,
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',
) {
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Models\EventParticipant;
class ShortSignUpResponse {
public bool $success;
public ?EventParticipant $participant;
public ?string $message = null;
public function __construct() {
$this->success = false;
$this->participant = null;
}
}
@@ -4,6 +4,7 @@ namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\ValueObjects\Age;
use Illuminate\Support\Facades\DB;
@@ -59,7 +60,7 @@ class SignUpCommand {
'participation_type' => $this->request->participationType,
'fee_type' => $this->request->feeType,
'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup->slug,
'local_group' => $this->request->localGroup?->slug,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2,
@@ -75,9 +76,7 @@ class SignUpCommand {
'medications' => $this->request->medications,
'tetanus_vaccination' => $this->request->tetanus_vaccination,
'eating_habit' => $eatingHabit,
'swimming_permission' => ($participantAge->isfullAged() || $this->request->swimming_permission === '-1')
? 'SWIMMING_PERMISSION_ALLOWED'
: $this->request->swimming_permission,
'swimming_permission' => $this->resolveSwimmingPermission($participantAge),
'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1')
? 'FIRST_AID_PERMISSION_ALLOWED'
: $this->request->first_aid_permission,
@@ -109,6 +108,26 @@ class SignUpCommand {
return $response;
}
/**
* Badeerlaubnis auflösen. Volljährige brauchen keine Erlaubnis. Für Minderjährige gilt die getroffene Auswahl;
* fehlt sie -- weil die Veranstaltung die Badeerlaubnis gar nicht abfragt oder keine Wahl getroffen wurde --,
* wird bewusst "Keine" hinterlegt und nicht etwa stillschweigend eine Erlaubnis angenommen.
*/
private function resolveSwimmingPermission(Age $participantAge): string
{
if ($participantAge->isfullAged()) {
return SwimmingPermission::SWIMMING_PERMISSION_ALLOWED;
}
if (!$this->request->event->swimming_permission_required
|| $this->request->swimming_permission === null
|| $this->request->swimming_permission === '-1') {
return SwimmingPermission::SWIMMING_PERMISSION_DENIED;
}
return $this->request->swimming_permission;
}
/**
* Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der
* Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben
@@ -15,7 +15,8 @@ class SignUpRequest {
public string $lastname,
public ?string $nickname,
public string $participationType,
public Tenant $localGroup,
/** Stamm des Teilnehmers; in der Kurzanmeldung nicht erhoben und deshalb optional. */
public ?Tenant $localGroup,
public DateTime $birthday,
public string $address_1,
public ?string $address_2,
@@ -13,6 +13,8 @@ class UpdateEventCommand {
$this->request->event->name = $this->request->eventName;
$this->request->event->location = $this->request->eventLocation;
$this->request->event->street = $this->normalizeOptional($this->request->street);
$this->request->event->house_number = $this->normalizeOptional($this->request->houseNumber);
$this->request->event->postal_code = $this->request->postalCode;
$this->request->event->email = $this->request->email;
$this->request->event->early_bird_end = $this->request->earlyBirdEnd;
@@ -22,6 +24,8 @@ class UpdateEventCommand {
$this->request->event->support_flat = $this->request->flatSupport;
$this->request->event->send_weekly_report = $this->request->sendWeeklyReports;
$this->request->event->registration_allowed = $this->request->registrationAllowed;
$this->request->event->short_registration = $this->request->shortRegistration;
$this->request->event->swimming_permission_required = $this->request->swimmingPermissionRequired;
$this->request->event->save();
$this->request->event->resetAllowedEatingHabits();
@@ -40,4 +44,15 @@ class UpdateEventCommand {
return $response;
}
/**
* Straße und Hausnummer sind optional. Ein geleertes Eingabefeld liefert einen leeren String --
* gespeichert wird dafür `null`, damit „nicht angegeben" nur eine Darstellung hat.
*/
private function normalizeOptional(?string $value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $value;
}
}
@@ -10,6 +10,8 @@ class UpdateEventRequest {
public Event $event;
public string $eventName;
public string $eventLocation;
public ?string $street;
public ?string $houseNumber;
public string $postalCode;
public string $email;
public DateTime $earlyBirdEnd;
@@ -17,16 +19,20 @@ class UpdateEventRequest {
public int $alcoholicsAge;
public bool $sendWeeklyReports;
public bool $registrationAllowed;
public bool $shortRegistration;
public bool $swimmingPermissionRequired;
public Amount $flatSupport;
public Amount $supportPerPerson;
public array $contributingLocalGroups;
public array $eatingHabits;
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits)
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, bool $shortRegistration = false, bool $swimmingPermissionRequired = true, ?string $street = null, ?string $houseNumber = null)
{
$this->event = $event;
$this->eventName = $eventName;
$this->eventLocation = $eventLocation;
$this->street = $street;
$this->houseNumber = $houseNumber;
$this->postalCode = $postalCode;
$this->email = $email;
$this->earlyBirdEnd = $earlyBirdEnd;
@@ -34,6 +40,8 @@ class UpdateEventRequest {
$this->alcoholicsAge = $alcoholicsAge;
$this->sendWeeklyReports = $sendWeeklyReports;
$this->registrationAllowed = $registrationAllowed;
$this->shortRegistration = $shortRegistration;
$this->swimmingPermissionRequired = $swimmingPermissionRequired;
$this->flatSupport = $flatSupport;
$this->supportPerPerson = $supportPerPerson;
$this->contributingLocalGroups = $contributingLocalGroups;
@@ -31,7 +31,8 @@ class UpdateParticipantCommand {
$p->address_2 = $this->request->address_2;
$p->postcode = $this->request->postcode;
$p->city = $this->request->city;
$p->local_group = $this->request->localgroup;
// Leerer String würde als Fremdschlüssel scheitern -- kein Stamm heißt null.
$p->local_group = $this->request->localgroup ?: null;
$p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$p->email_1 = $this->request->email_1;
$p->phone_1 = $this->request->phone_1;
@@ -14,7 +14,8 @@ class UpdateParticipantRequest {
public ?string $address_2,
public string $postcode,
public string $city,
public string $localgroup,
/** Stamm-Slug; leer/null bei Anmeldungen, die keinen Stamm erfasst haben (Kurzanmeldung). */
public ?string $localgroup,
public string $birthday,
public string $email_1,
public string $phone_1,
@@ -17,6 +17,7 @@ class ArchivedEventsController extends CommonController
'name' => $event->name,
'location' => $event->location,
'postalCode' => $event->postal_code,
'fullAddress' => $event->getFullAddress(),
'eventBegin' => $event->start_date->format('d.m.Y'),
'eventEnd' => $event->end_date->format('d.m.Y'),
];
@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Bucht die in der Prüfansicht bestätigten Zahlungseingänge -- ein Aufruf für alle Zeilen.
*/
class BankStatementBookController extends CommonController
{
public function __invoke(int $eventId, Request $request): JsonResponse
{
$event = $this->events->getById($eventId);
if ($event === null) {
return response()->json(['status' => 'error', 'message' => 'Die Veranstaltung wurde nicht gefunden.']);
}
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $event,
bookings: (array) $request->input('bookings', []),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'booked' => $response->booked,
'skipped' => $response->skipped,
'failed' => $response->failed,
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
use App\Models\PaymentMethod;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Nimmt den CSV-Export der Bank entgegen und liefert die Prüfansicht zurück. Gebucht wird hier nichts.
*/
class BankStatementParseController extends CommonController
{
public function __invoke(int $eventId, Request $request): JsonResponse
{
$event = $this->events->getById($eventId);
if ($event === null) {
return response()->json(['status' => 'error', 'message' => 'Die Veranstaltung wurde nicht gefunden.']);
}
// Das Kontoauszug-Format kommt vom Mandanten, nicht aus dem Event-Snapshot: Ein Bankwechsel
// muss sofort auch für laufende Aktionen gelten.
$configuration = $this->paymentMethods->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
$response = new ParseBankStatementCommand(new ParseBankStatementRequest(
event: $event,
file: $request->file('statement'),
configuration: $configuration,
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'rows' => $response->rows,
'participants' => $response->participants,
'skipped' => $response->skippedOlderThanWatermark,
]);
}
}
@@ -56,7 +56,9 @@ class CreateController extends CommonController {
$participationFeeType,
$request->input('eventAccount'),
$request->input('eventIban'),
$payPerDay
$payPerDay,
$request->input('eventStreet'),
$request->input('eventHouseNumber')
);
$wasSuccessful = false;
@@ -60,7 +60,11 @@ class DetailsController extends CommonController {
$flatSupport,
$supportPerPerson,
$contributinLocalGroups,
$eatingHabits
$eatingHabits,
(bool)$request->input('shortRegistration', false),
(bool)$request->input('swimmingPermissionRequired', true),
$request->input('street'),
$request->input('houseNumber'),
);
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Event\Controllers;
use App\RelationModels\EventParticipationFee;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
/**
* 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` 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);
$participationFee = $event->firstParticipationFee();
$eventResource = $event->toResource();
$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 [
'value' => $feeType,
'label' => EventParticipationFee::FEE_TYPE_LABELS[$feeType],
'amount' => $amount->toString(),
'amountValue' => $amount->getAmount(),
];
}, $participationFee?->availableFeeTypes() ?? ['standard']);
return response()->json(['feeTypes' => $feeTypes]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckCommand;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckRequest;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
/**
* Nimmt die verkürzte Anmeldung entgegen. Öffentlich erreichbar, deshalb werden die Eingaben hier -- anders als
* im langen Prozess -- validiert, bevor sie die Action erreichen.
*/
class ShortSignupController extends CommonController {
public function __invoke(int $eventId, Request $request) : JsonResponse {
$event = $this->events->getById($eventId, false);
if (!$event->registration_allowed || new DateTime() > $event->start_date) {
return response()->json(['status' => 'closed'], 403);
}
// Die Kurzanmeldung darf nicht als Abkürzung um den langen Prozess herum benutzt werden.
if (!$event->short_registration) {
return response()->json(['status' => 'closed'], 403);
}
$validator = Validator::make($request->all(), [
'firstname' => 'required|string|max:255',
'lastname' => 'required|string|max:255',
'nickname' => 'nullable|string|max:255',
'birthday' => 'required|date_format:Y-m-d|before:today',
'email' => 'required|email|max:255',
'phone' => 'required|string|max:255',
// Gegen die teilnehmenden Stämme prüft der Command -- hier reicht die Typprüfung.
'localGroup' => 'nullable|integer',
'contactPerson' => 'nullable|string|max:255',
'contactEmail' => 'nullable|email|max:255',
'allergies' => 'nullable|string|max:255',
'intolerances' => 'nullable|string|max:255',
'firstAidPermission' => ['nullable', 'string', 'in:' . implode(',', FirstAidPermission::query()->pluck('slug')->toArray())],
'swimmingPermission' => ['nullable', 'string', 'in:' . implode(',', SwimmingPermission::query()->pluck('slug')->toArray())],
'foto' => 'array',
'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()) {
return response()->json(['status' => 'error', 'message' => $validator->errors()->first()], 422);
}
$birthday = DateTime::createFromFormat('Y-m-d', $request->input('birthday'));
$doubleCheck = new DoubleCheckEventRegistrationProvider(
$event,
$request->input('firstname'),
$request->input('lastname'),
$request->input('email'),
$birthday
);
if ($doubleCheck->isRegistered()) {
return response()->json(['status' => 'exists']);
}
$foto = (array)$request->input('foto', []);
$shortSignUpRequest = new ShortSignUpRequest(
event: $event,
user_id: currentUser()?->id,
firstname: $request->input('firstname'),
lastname: $request->input('lastname'),
nickname: $request->input('nickname'),
birthday: $birthday,
email: $request->input('email'),
phone: $request->input('phone'),
localGroupId: $request->input('localGroup') !== null ? (int)$request->input('localGroup') : null,
contactPerson: $request->input('contactPerson') ?: null,
contactEmail: $request->input('contactEmail') ?: null,
allergies: $request->input('allergies') ?: null,
intolerances: $request->input('intolerances') ?: null,
firstAidPermission: $request->input('firstAidPermission') ?: null,
swimmingPermission: $request->input('swimmingPermission') ?: null,
foto_socialmedia: (bool)($foto['socialmedia'] ?? false),
foto_print: (bool)($foto['print'] ?? false),
foto_webseite: (bool)($foto['webseite'] ?? false),
foto_partner: (bool)($foto['partner'] ?? false),
foto_intern: (bool)($foto['intern'] ?? false),
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();
if (!$shortSignUpResponse->success) {
return response()->json(['status' => 'error', 'message' => $shortSignUpResponse->message], 422);
}
$participant = $shortSignUpResponse->participant;
$cocResponse = new CertificateOfConductionCheckCommand(
new CertificateOfConductionCheckRequest($participant)
)->execute();
$participant->efz_status = $cocResponse->status;
$participant->save();
Mail::to($participant->email_1)->send(new EventSignUpSuccessfullMail(participant: $participant));
if ($participant->email_2 !== null) {
Mail::to($participant->email_2)->send(new EventSignUpSuccessfullMail(participant: $participant));
}
return response()->json([
'participant' => $participant->toResource()->toArray($request),
'status' => 'success',
]);
}
}
+10
View File
@@ -1,5 +1,7 @@
<?php
use App\Domains\Event\Controllers\BankStatementBookController;
use App\Domains\Event\Controllers\BankStatementParseController;
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\EventArchiveController;
@@ -14,6 +16,8 @@ use App\Domains\Event\Controllers\ParticipantReSignOnController;
use App\Domains\Event\Controllers\ParticipantSignOffController;
use App\Domains\Event\Controllers\ParticipantUpdateController;
use App\Domains\Event\Controllers\PaymentReminderController;
use App\Domains\Event\Controllers\ShortCalculateAmountController;
use App\Domains\Event\Controllers\ShortSignupController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -25,6 +29,9 @@ Route::prefix('api/v1')
Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']);
Route::post('{eventId}/signup', [SignupController::class, 'signUp']);
Route::post('{eventId}/short-calculate-amount', ShortCalculateAmountController::class);
Route::post('{eventId}/short-signup', ShortSignupController::class);
Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']);
@@ -48,6 +55,9 @@ Route::prefix('api/v1')
Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']);
Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']);
Route::post('/event-addons', [DetailsController::class, 'updateEventAddons']);
Route::post('/bank-statement/parse', BankStatementParseController::class);
Route::post('/bank-statement/book', BankStatementBookController::class);
});
+1 -1
View File
@@ -47,7 +47,7 @@ async function unarchiveEvent(eventId) {
<div>
<h2 style="margin: 0 0 4px 0; font-size: 1.1rem; font-weight: 600;">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.875rem;">
{{ event.postalCode }} {{ event.location }}
{{ event.fullAddress }}
&nbsp;·&nbsp;
{{ event.eventBegin }} – {{ event.eventEnd }}
</span>
+14
View File
@@ -23,6 +23,8 @@
eventName: '',
eventPostalCode: '',
eventLocation: '',
eventStreet: '',
eventHouseNumber: '',
eventEmail: props.emailAddress ? props.emailAddress : '',
eventBegin: '',
eventEnd: '',
@@ -145,6 +147,8 @@
eventName: formData.eventName,
eventPostalCode: formData.eventPostalCode,
eventLocation: formData.eventLocation,
eventStreet: formData.eventStreet,
eventHouseNumber: formData.eventHouseNumber,
eventEmail: formData.eventEmail,
eventBegin: formData.eventBegin,
eventEnd: formData.eventEnd,
@@ -195,6 +199,16 @@
<ErrorText :message="errors.eventLocation" /></td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Straße (optional)</th>
<td class="height-50"><input type="text" v-model="formData.eventStreet" class="width-half-full" /></td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Hausnummer (optional)</th>
<td class="height-50"><input type="text" v-model="formData.eventHouseNumber" class="width-half-full" /></td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Postleitzahl des Veranstaltungsorts</th>
<td class="height-50"><input type="text" v-model="formData.eventPostalCode" class="width-half-full" />
@@ -22,7 +22,7 @@ const props = defineProps({
<div class="available-event-header">
<div>
<h2 class="available-event-title">{{ event.name }}</h2>
<span class="available-event-location">{{ event.postalCode }} {{ event.location }}</span>
<span class="available-event-location">{{ event.fullAddress }}</span>
</div>
<span
v-if="event.registrationAllowed"
@@ -47,7 +47,7 @@ const props = defineProps({
</tr>
<tr>
<th>Veranstaltungsort</th>
<td>{{ event.postalCode }} {{ event.location }}</td>
<td>{{ event.fullAddress }}</td>
</tr>
<tr>
<th>Frühbuchen bis</th>
@@ -0,0 +1,635 @@
<script setup>
import {computed, ref} from 'vue'
import {toast} from 'vue3-toastify'
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
import Icon from "../../../../Views/Components/Icon.vue";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
/**
* Zahlungseingänge aus dem CSV-Export der Bank einbuchen.
*
* Zwei Schritte: Datei einlesen (der Server ordnet zu und schlägt vor), dann die Prüfansicht
* bestätigen. Gebucht wird erst im zweiten Schritt und für alle Zeilen in einem Aufruf.
*/
const props = defineProps({
event: {type: Object, required: true},
})
const emit = defineEmits(['close', 'booked'])
const {request} = useAjax()
const fileInput = ref(null)
const fileName = ref('')
const parsing = ref(false)
const booking = ref(false)
const rows = ref([])
const participants = ref([])
// rowNumber -> identifier ('' = ignorieren)
const assignment = ref({})
const parsed = ref(false)
const skipped = ref(0)
const assignedCount = computed(
() => rows.value.filter(row => isAssigned(row)).length,
)
const openCount = computed(() => rows.value.length - assignedCount.value)
const participantsByIdentifier = computed(
() => Object.fromEntries(participants.value.map(participant => [participant.identifier, participant])),
)
/**
* Die Auswahlliste für alle Zeilen -- einmal gebaut, nicht je Zeile.
*
* Getrennt nach an- und abgemeldet, weil die Entscheidung unterschiedlich weit trägt: Eine Buchung
* auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. Als Fließtext in einer Zeile ging
* das unter, als Gruppenüberschrift steht es da.
*/
const participantOptions = computed(() => {
const options = [{value: '', label: '— ignorieren —'}]
for (const participant of participants.value) {
const state = participant.isSettled ? 'vollständig bezahlt' : participant.amountOpen + ' offen'
options.push(participant.isSignedOff
? {
value: participant.identifier,
label: participant.name,
description: 'abgemeldet am ' + participant.signedOffAt + ' · ' + state,
group: 'Abgemeldete Teilis',
icon: 'user-slash',
muted: true,
}
: {
value: participant.identifier,
label: participant.name,
description: state,
group: 'Angemeldete Teilis',
})
}
return options
})
function isAssigned(row) {
return (assignment.value[row.rowNumber] ?? '') !== ''
}
/** Der sichtbare Button klickt das versteckte Datei-Element -- wie beim Beleg-Upload der Abrechnung. */
function chooseFile() {
fileInput.value?.click()
}
/** Auswählen und Einlesen sind ein Schritt: Ein zweiter Klick brächte nichts zu entscheiden. */
async function onFileChosen(event) {
const file = event.target.files?.[0] ?? null
if (file === null) {
return
}
fileName.value = file.name
await readStatement(file)
}
async function readStatement(file) {
parsing.value = true
try {
const form = new FormData()
form.append('statement', file)
const response = await request(
'/api/v1/event/details/' + props.event.id + '/bank-statement/parse',
{method: 'POST', body: form},
)
if (response?.status !== 'success') {
toast.error(response?.message ?? 'Die Datei konnte nicht gelesen werden.')
reset()
return
}
rows.value = response.rows ?? []
participants.value = response.participants ?? []
skipped.value = response.skipped ?? 0
// Vorschläge sind vorbelegt, aber nichts ist entschieden -- gebucht wird nur, was hier
// stehen bleibt.
assignment.value = Object.fromEntries(
rows.value.map(row => [row.rowNumber, row.suggestedIdentifier ?? '']),
)
parsed.value = true
} finally {
parsing.value = false
}
}
async function bookPayments() {
const bookings = rows.value
.filter(row => isAssigned(row))
.map(row => ({
participantIdentifier: assignment.value[row.rowNumber],
rowNumber: row.rowNumber,
paymentDate: row.paymentDate,
amount: row.amount,
payerName: row.payerName,
payerIban: row.payerIban,
purpose: row.purpose,
}))
if (bookings.length === 0) {
toast.error('Es ist keine Zahlung zugeordnet.')
return
}
booking.value = true
try {
const response = await request(
'/api/v1/event/details/' + props.event.id + '/bank-statement/book',
{method: 'POST', body: {bookings}},
)
if (response?.status !== 'success') {
toast.error(response?.message ?? 'Die Zahlungen konnten nicht gebucht werden.')
return
}
toast.success(response.message)
emit('booked')
emit('close')
} finally {
booking.value = false
}
}
function reset() {
rows.value = []
participants.value = []
assignment.value = {}
parsed.value = false
skipped.value = 0
fileName.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function assignedParticipant(row) {
return participantsByIdentifier.value[assignment.value[row.rowNumber]] ?? null
}
/** Der Zustand einer Zeile: was gebucht wird, was noch offen ist, was geprüft gehört. */
function rowState(row) {
if (!isAssigned(row)) {
return row.suggestedIdentifier === null
? {key: 'unmatched', label: 'Keine Zuordnung', icon: 'circle-question'}
: {key: 'ignored', label: 'Ignoriert', icon: 'ban'}
}
if (assignment.value[row.rowNumber] !== row.suggestedIdentifier) {
return {key: 'manual', label: 'Von Hand', icon: 'user-pen'}
}
return row.confidence === 'unsicher'
? {key: 'uncertain', label: 'Bitte prüfen', icon: 'triangle-exclamation'}
: {key: 'certain', label: 'Vorschlag', icon: 'check'}
}
function lastPaymentOf(row) {
return assignedParticipant(row)?.lastPaymentDate ?? null
}
/** Eine Zahlung auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. */
function signedOffOf(row) {
const participant = assignedParticipant(row)
return participant?.isSignedOff === true ? participant : null
}
</script>
<template>
<FullScreenModal :show="true" @close="emit('close')">
<div class="import">
<header class="import-header">
<h2>Zahlungseingänge einlesen</h2>
<p class="subtitle">{{ event.name }}</p>
</header>
<!-- Schritt 1: Datei wählen. Das Datei-Element bleibt versteckt, geklickt wird der Button. -->
<div v-if="!parsed" class="dropzone">
<Icon name="file-csv" class="dropzone-icon"/>
<p class="dropzone-title">Kontoauszug als CSV hochladen</p>
<p class="dropzone-note">
Der Export wird nur gelesen und nicht gespeichert. Gebucht wird anschließend
ausschließlich das, was ihr in der Prüfansicht bestätigt.
</p>
<input type="button" value="Kontoauszug auswählen" @click="chooseFile"/>
</div>
<input ref="fileInput" type="file" accept=".csv,text/csv,text/plain"
style="display: none" @change="onFileChosen"/>
<template v-if="parsed">
<div class="filebar">
<span class="filename"><Icon name="file-csv"/> {{ fileName }}</span>
<label class="link" @click="reset">Andere Datei wählen</label>
</div>
<div v-if="rows.length > 0" class="stats">
<div class="stat stat-assigned">
<span class="stat-value">{{ assignedCount }}</span>
<span class="stat-label">wird gebucht</span>
</div>
<div class="stat" :class="{'stat-open': openCount > 0}">
<span class="stat-value">{{ openCount }}</span>
<span class="stat-label">nicht zugeordnet</span>
</div>
<div v-if="skipped > 0" class="stat">
<span class="stat-value">{{ skipped }}</span>
<span class="stat-label">bereits erfasst</span>
</div>
</div>
<div v-if="rows.length === 0" class="empty">
<Icon name="circle-check" class="empty-icon"/>
<p>
In dieser Datei sind keine offenen Zahlungseingänge zu dieser Aktion enthalten.
<template v-if="skipped > 0">
{{ skipped }} Zahlungen wurden bereits früher eingebucht.
</template>
</p>
</div>
<table v-else class="statement-table">
<thead>
<tr>
<th>Buchung</th>
<th class="right">Betrag</th>
<th>Zahler*in</th>
<th>Verwendungszweck</th>
<th class="assignment-column">Zuordnung</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.rowNumber" :class="'state-' + rowState(row).key">
<td class="nowrap">{{ row.paymentDateFormatted }}</td>
<td class="right amount">{{ row.amountFormatted }}</td>
<td>
<span class="payer">{{ row.payerName || '—' }}</span>
<span v-if="row.payerIban" class="iban">{{ row.payerIban }}</span>
</td>
<td class="purpose">{{ row.purpose }}</td>
<td>
<div class="assignment">
<RichSelectBox v-model="assignment[row.rowNumber]"
:options="participantOptions"
placeholder="— ignorieren —"
filterable/>
<span class="pill" :class="'pill-' + rowState(row).key">
<Icon :key="rowState(row).icon" :name="rowState(row).icon"/> {{ rowState(row).label }}
</span>
<span v-if="signedOffOf(row)" class="pill pill-signedoff">
<Icon name="user-slash"/> Abgemeldet
</span>
</div>
<span v-if="signedOffOf(row)" class="hint hint-warning">
Am {{ signedOffOf(row).signedOffAt }} abgemeldet — die Zahlung wird
erfasst, danach steht eine Erstattung an.
</span>
<span v-if="lastPaymentOf(row)" class="hint">
Zuletzt erfasst: {{ lastPaymentOf(row) }}
</span>
</td>
</tr>
</tbody>
</table>
<footer v-if="rows.length > 0" class="actions">
<span class="actions-note">
Die Teilnehmenden erhalten je Buchung die gewohnte Zahlungsmail.
</span>
<input type="button" class="accept-button"
:value="assignedCount === 1 ? '1 Zahlung einbuchen' : assignedCount + ' Zahlungen einbuchen'"
:disabled="assignedCount === 0 || booking"
@click="bookPayments"/>
</footer>
</template>
</div>
<!-- Beim Buchen geht je Zahlung eine Mail an die Teilnehmenden raus, und zwar synchron --
bei achtzig Buchungen dauert der Aufruf entsprechend. -->
<LoadingModal v-if="parsing || booking" :show="true"
:message="booking
? 'Die Zahlungen werden gebucht und die Teilnehmenden benachrichtigt. Das kann einen Moment dauern …'
: 'Der Kontoauszug wird gelesen …'"/>
</FullScreenModal>
</template>
<style scoped>
.import {
max-width: 1200px;
margin: 0 auto;
}
.import-header {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 12px;
margin-bottom: 20px;
}
.import-header h2 {
margin: 0;
color: #1d4899;
}
.subtitle {
margin: 2px 0 0 0;
color: #6b7280;
font-size: 0.9rem;
}
/* ── Schritt 1: Datei wählen ───────────────────────────────────────────── */
.dropzone {
border: 2px dashed #c7d2e8;
border-radius: 10px;
background-color: #fafbfe;
padding: 48px 24px;
text-align: center;
max-width: 620px;
margin: 40px auto;
}
.dropzone-icon {
font-size: 2.6rem;
color: #809dd5;
}
.dropzone-title {
margin: 14px 0 6px 0;
font-weight: bold;
font-size: 1.05rem;
color: #374151;
}
.dropzone-note {
margin: 0 auto 22px auto;
max-width: 440px;
color: #6b7280;
font-size: 0.85rem;
line-height: 1.5;
}
/* ── Schritt 2: Prüfansicht ────────────────────────────────────────────── */
.filebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 8px 12px;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.filename {
color: #374151;
font-weight: bold;
font-size: 0.9rem;
word-break: break-all;
}
.stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 16px 0;
}
.stat {
flex: 1 1 130px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 14px;
background-color: #ffffff;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: bold;
color: #374151;
line-height: 1.1;
}
.stat-label {
display: block;
color: #6b7280;
font-size: 0.8rem;
margin-top: 2px;
}
.stat-assigned {
border-color: #508c4c;
background-color: #f3faf3;
}
.stat-assigned .stat-value {
color: #2f6b2c;
}
.stat-open .stat-value {
color: #92400e;
}
.empty {
text-align: center;
color: #6b7280;
padding: 40px 20px;
}
.empty-icon {
font-size: 2rem;
color: #508c4c;
}
.statement-table {
width: 100%;
border-collapse: collapse;
margin-top: 6px;
}
.statement-table th {
text-align: left;
padding: 8px 10px;
background-color: #f9fafb;
color: #374151;
border-bottom: 2px solid #d1d5db;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
position: sticky;
top: 0;
z-index: 1;
}
.statement-table td {
padding: 10px;
border-bottom: 1px solid #e5e7eb;
vertical-align: top;
font-size: 0.95rem;
}
.statement-table .right {
text-align: right;
}
.nowrap {
white-space: nowrap;
}
.amount {
white-space: nowrap;
font-weight: bold;
color: #374151;
}
.assignment-column {
width: 360px;
}
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
grau bleibt liegen. */
.state-certain td,
.state-manual td {
background-color: #f6fdf7;
}
.state-uncertain td {
background-color: #fffbeb;
}
.state-ignored td,
.state-unmatched td {
background-color: #fbfbfb;
color: #9ca3af;
}
.payer {
display: block;
color: #374151;
}
.iban {
display: block;
color: #9ca3af;
font-size: 0.75rem;
margin-top: 1px;
}
.purpose {
max-width: 320px;
word-break: break-word;
color: #4b5563;
}
.assignment {
display: flex;
flex-direction: column;
gap: 5px;
}
.pill {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: bold;
border: 1px solid transparent;
}
.pill-certain {
color: #2f6b2c;
background-color: #dcfce7;
border-color: #86c884;
}
.pill-manual {
color: #1d4899;
background-color: #e4ecfb;
border-color: #809dd5;
}
.pill-uncertain {
color: #92400e;
background-color: #fef3c7;
border-color: #d9b45c;
}
.pill-ignored,
.pill-unmatched {
color: #6b7280;
background-color: #f3f4f6;
border-color: #d1d5db;
}
.pill-signedoff {
color: #9a3412;
background-color: #ffedd5;
border-color: #e0a06a;
}
.hint {
display: block;
color: #9ca3af;
font-size: 0.72rem;
margin-top: 4px;
}
.hint-warning {
color: #9a3412;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
flex-wrap: wrap;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.actions-note {
color: #6b7280;
font-size: 0.8rem;
}
.actions input[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Schmale Bildschirme ───────────────────────────────────────────────── */
@media (max-width: 900px) {
.assignment-column {
width: auto;
}
.statement-table th {
position: static;
}
.purpose {
max-width: none;
}
}
</style>
@@ -27,6 +27,8 @@ const emit = defineEmits(['close'])
contributingLocalGroups: contributingLocalGroups.value,
eventName: props.event.name,
eventLocation: props.event.location,
street: props.event.street ?? '',
houseNumber: props.event.houseNumber ?? '',
postalCode: props.event.postalCode,
email: props.event.email,
earlyBirdEnd: props.event.earlyBirdEnd.internal,
@@ -35,6 +37,8 @@ const emit = defineEmits(['close'])
eatingHabits: eatingHabits.value,
sendWeeklyReports: props.event.sendWeeklyReports,
registrationAllowed: props.event.registrationAllowed,
shortRegistration: props.event.shortRegistration,
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
flatSupport: props.event.flatSupportEdit,
supportPerson: props.event.supportPersonIndex,
})
@@ -57,6 +61,8 @@ const emit = defineEmits(['close'])
body: {
eventName: formData.eventName,
eventLocation: formData.eventLocation,
street: formData.street,
houseNumber: formData.houseNumber,
postalCode: formData.postalCode,
email: formData.email,
earlyBirdEnd: formData.earlyBirdEnd,
@@ -64,6 +70,8 @@ const emit = defineEmits(['close'])
alcoholicsAge: formData.alcoholicsAge,
sendWeeklyReports: formData.sendWeeklyReports,
registrationAllowed: formData.registrationAllowed,
shortRegistration: formData.shortRegistration,
swimmingPermissionRequired: formData.swimmingPermissionRequired,
flatSupport: formData.flatSupport,
supportPerson: formData.supportPerson,
contributingLocalGroups: contributingLocalGroups.value,
@@ -105,6 +113,20 @@ const emit = defineEmits(['close'])
</td>
</tr>
<tr>
<th>Straße (optional)</th>
<td>
<input type="text" v-model="formData.street" class="width-full" />
</td>
</tr>
<tr>
<th>Hausnummer (optional)</th>
<td>
<input type="text" v-model="formData.houseNumber" class="width-full" />
</td>
</tr>
<tr>
<th>Postleitzahl des Veranstaltungsorts</th>
<td>
@@ -172,6 +194,27 @@ const emit = defineEmits(['close'])
<label for="registrationAllowed">Veranstaltung ist für Anmeldungen geöffnet</label>
</td>
</tr>
<tr>
<td colspan="2">
<input type="checkbox" v-model="formData.shortRegistration" id="shortRegistration" />
<label for="shortRegistration">Verkürzte Anmeldung verwenden</label><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Fragt nur die nötigsten Daten ab (Name, Kontakt, Allergien, Foto-Erlaubnis).
Nicht erhobene Angaben wie Anschrift und An-/Abreise werden automatisch gefüllt.
</span>
</td>
</tr>
<tr>
<td colspan="2">
<input type="checkbox" v-model="formData.swimmingPermissionRequired" id="swimmingPermissionRequired" />
<label for="swimmingPermissionRequired">Badeerlaubnis abfragen</label><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Ist die Abfrage deaktiviert, wird für Minderjährige „Keine Badeerlaubnis" hinterlegt.
</span>
</td>
</tr>
</table>
@@ -6,6 +6,7 @@ import EventAddons from "./EventAddons.vue";
import ParticipationSummary from "./ParticipationSummary.vue";
import CommonSettings from "./CommonSettings.vue";
import EventManagement from "./EventManagement.vue";
import BankStatementImport from "./BankStatementImport.vue";
import Modal from "../../../../Views/Components/Modal.vue";
import MailCompose from "./MailCompose.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
@@ -21,6 +22,12 @@ const props = defineProps({
const displayData = ref('main');
const showEventData = ref(false);
const showBankStatement = ref(false);
// Nach dem Buchen stimmen die Beitragsstände der Übersicht nicht mehr -- neu laden.
async function bankStatementBooked() {
await showMain();
}
async function showMain() {
@@ -117,9 +124,18 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/>
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
</div>
<!-- Liegt außerhalb der displayData-Umschaltung: Der Import ist ein Vollbild-Dialog über der
Übersicht, keine Unter-Ansicht, und soll auch aus einer Unter-Ansicht heraus erreichbar sein. -->
<BankStatementImport v-if="showBankStatement && dynamicProps.event"
:event="dynamicProps.event"
@close="showBankStatement = false"
@booked="bankStatementBooked" />
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
@close="showMain"/>
@@ -158,6 +174,7 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/>
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
<input type="button" @click="sendPaymentReminder" class="fix-button" value="Zahlungserinnerung senden" /><br/>
<input type="button" class="deny-button" value="Letzte Mahnung senden" style="display: none" /><br/>
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
@@ -233,6 +233,7 @@ function saveParticipant() {
<td>
<span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span>
<select v-else v-model="form.localgroup">
<option value="">Kein Stamm</option>
<option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option>
</select>
</td>
@@ -86,6 +86,16 @@ const retainedAmount = computed(() => {
const hasRetention = computed(() => retainedAmount.value > 0);
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Ist es da, wird es weder erfragt noch bearbeitet: Erstattet wird auf das Konto, von dem der Beitrag
* kam, und genau das steht hier. `null`, solange nichts bekannt ist (Barzahlung, Altbestand).
*/
const knownRefundAccount = computed(
() => showParticipant.value?.refundData?.available ? showParticipant.value.refundData : null
);
const retainedAmountReadable = computed(
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro'
);
@@ -121,7 +131,8 @@ const refundFormComplete = computed(() => {
}
}
if (refundForm.captureMode === 'management') {
// Beim bekannten Konto gibt es nichts einzugeben -- es steht fest.
if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== '';
}
@@ -429,7 +440,7 @@ function validateRefund() {
// Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
if (refundForm.captureMode === 'management') {
if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
}
@@ -456,9 +467,14 @@ async function execRefund() {
amount: refundForm.amount,
reason: refundForm.reason,
reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. Beim
// Sofort-Einreichen mit bekanntem Konto geht dieses mit; eingegeben wurde nichts.
accountOwner: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountOwner ?? refundForm.accountOwner)
: '',
accountIban: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountIban ?? refundForm.accountIban)
: '',
// Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
@@ -805,14 +821,36 @@ function mailToGroup(groupKey) {
Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn:
die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem.
-->
<!--
Ist das Konto aus dem Zahlungseingang bekannt, wird es weder erfragt noch bearbeitet: Es
steht fest, dass auf genau dieses Konto zu erstatten ist. Der Teili entscheidet dann nur
noch, ob er es haben oder spenden möchte.
-->
<div v-if="knownRefundAccount" class="refund-known-account">
<strong>Konto aus dem Zahlungseingang</strong>
<span>{{ knownRefundAccount.accountOwner }}</span>
<span class="refund-known-account__iban">{{ knownRefundAccount.accountIban }}</span>
<span class="refund-known-account__source">{{ knownRefundAccount.source }}</span>
</div>
<div class="refund-field">
<label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" />
<template v-if="knownRefundAccount">
Teilnehmer*in bestätigt die Erstattung oder spendet
</template>
<template v-else>
Teilnehmer*in trägt die Bankverbindung selbst ein
</template>
</label>
<label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" />
<template v-if="knownRefundAccount">
Sofort einreichen, ohne auf die Rückmeldung zu warten
</template>
<template v-else>
Bankverbindung liegt mir vor
</template>
</label>
<label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" />
@@ -825,7 +863,13 @@ function mailToGroup(groupKey) {
eingereicht; der Teili erhält den Beleg per E-Mail.
</p>
<template v-if="refundForm.captureMode === 'management'">
<!-- Bekanntes Konto: nichts einzugeben, es wird ohnehin dieses genommen. -->
<p v-if="refundForm.captureMode === 'management' && knownRefundAccount" class="refund-hint">
Die Erstattung wird sofort auf das oben genannte Konto eingereicht. Der Teili erhält den
Beleg per E-Mail, wird aber nicht mehr nach einer Spende gefragt.
</p>
<template v-if="refundForm.captureMode === 'management' && !knownRefundAccount">
<div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label>
<input
@@ -882,6 +926,31 @@ function mailToGroup(groupKey) {
color: #4b5563;
}
/* Anzeige, kein Feld: bewusst nicht wie ein Eingabeblock gesetzt, damit gar nicht erst der Eindruck
entsteht, hier ließe sich etwas ändern. */
.refund-known-account {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: #f9fafb;
font-size: 0.9rem;
}
.refund-known-account__iban {
font-family: monospace;
letter-spacing: 0.04em;
color: #4b5563;
}
.refund-known-account__source {
color: #6b7280;
font-size: 0.8rem;
}
.refund-field {
margin-bottom: 14px;
}
@@ -26,6 +26,18 @@ const paymentMethodConfigurations = reactive({})
const showOptionsModal = ref(false)
const optionsMethod = ref(null)
/**
* Die Optionen, die pro Aktion eingefroren werden.
*
* Optionen mit `scope: 'tenant'` bleiben draußen: Sie gelten für den ganzen Mandanten. Das
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden — hier
* eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren.
* Der Server hält sich beim Speichern an dieselbe Regel.
*/
function eventOptions(method) {
return (method?.optionsSchema ?? []).filter(option => option.scope !== 'tenant')
}
function openOptions(method) {
optionsMethod.value = method
showOptionsModal.value = true
@@ -47,7 +59,7 @@ onMounted(async () => {
for (const method of availablePaymentMethods.value) {
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
const config = {}
for (const option of method.optionsSchema ?? []) {
for (const option of eventOptions(method)) {
config[option.name] = source[option.name] ?? ''
}
paymentMethodConfigurations[method.slug] = config
@@ -358,7 +370,7 @@ onMounted(async () => {
paymentMethod.name
}}</label>
<label
v-if="paymentMethods.includes(paymentMethod.slug) && (paymentMethod.optionsSchema?.length ?? 0) > 0"
v-if="paymentMethods.includes(paymentMethod.slug) && eventOptions(paymentMethod).length > 0"
class="link"
style="padding-left: 15px; font-size: 10pt;"
@click="openOptions(paymentMethod)"
@@ -381,7 +393,7 @@ onMounted(async () => {
width="700px"
@close="showOptionsModal = false"
>
<div v-for="option in optionsMethod?.optionsSchema ?? []" :key="option.name" class="payment-method-config-row">
<div v-for="option in eventOptions(optionsMethod)" :key="option.name" class="payment-method-config-row">
<label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
<RichSelectBox v-if="option.type === 'icon'"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
@@ -0,0 +1,146 @@
<script setup>
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,
SHORT_STEP_SUMMARY,
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'
import StepPaymentMethod from '../SignUpForm/steps/StepPaymentMethod.vue'
import SubmitSuccess from '../SignUpForm/after-submit/SubmitSuccess.vue'
import SubmitAlreadyExists from '../SignUpForm/after-submit/SubmitAlreadyExists.vue'
const props = defineProps({
event: Object,
participantData: Object,
})
const {
currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup,
submit, submitting, submitResult, submitError,
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
// nur, dass "weiter" bzw. "zurück" gedrückt wurde -- die Reihenfolge kennt dieser Host.
const onNext = () => goNext()
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'},
{step: SHORT_STEP_SUMMARY, label: 'Zusammenfassung'},
]
// 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, flowState.value))
)
const currentIndex = computed(() => steps.value.findIndex(s => s.step === currentStep.value))
</script>
<template>
<div>
<!-- Nach Submit -->
<SubmitSuccess
v-if="submitResult?.status === 'success'"
:participant="submitResult?.participant"
:event="event"
/>
<SubmitAlreadyExists v-else-if="submitResult?.status === 'exists'" :event="event" />
<template v-else>
<!-- Fortschrittsleiste -->
<div class="signup-progress">
<div class="signup-progress-pills">
<template v-for="(s, index) in steps" :key="s.step">
<div v-if="index > 0" class="signup-progress-separator"></div>
<div
class="signup-pill"
:class="{
'signup-pill--active': currentStep === s.step,
'signup-pill--done': currentStep > s.step,
'signup-pill--upcoming': currentStep < s.step,
}"
@click="currentStep > s.step ? goToStep(s.step) : null"
>
<span v-if="currentStep > s.step" class="signup-pill__check">✓</span>
{{ s.label }}
</div>
</template>
</div>
<div class="signup-progress-bar">
<div
class="signup-progress-bar__fill"
:style="{ width: (steps.length > 1 ? (currentIndex / (steps.length - 1)) * 100 : 100) + '%' }"
></div>
</div>
<div class="signup-progress-mobile">
Schritt {{ currentIndex + 1 }} von {{ steps.length }}:
<strong>{{ steps.find(s => s.step === currentStep)?.label }}</strong>
</div>
</div>
<!-- Steps -->
<form @submit.prevent="submit">
<ShortStepPerson
v-if="currentStep === SHORT_STEP_PERSON"
:formData="formData" :event="event" :isMinor="isMinor"
:localGroups="localGroups" :needsLocalGroup="needsLocalGroup"
@next="onNext"
/>
<ShortStepFee
v-if="currentStep === SHORT_STEP_FEE"
:formData="formData" :feeOptions="feeOptions"
@next="onNext" @back="onBack"
/>
<StepParticipationOptions
v-if="currentStep === SHORT_STEP_PARTICIPATION_OPTIONS"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPhotoPermissions
v-if="currentStep === SHORT_STEP_PHOTO"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPaymentMethod
v-if="currentStep === SHORT_STEP_PAYMENT"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<ShortStepSummary
v-if="currentStep === SHORT_STEP_SUMMARY"
:formData="formData"
:event="event"
:isMinor="isMinor"
:summaryAmount="summaryAmount"
:feeLabel="feeOptions.length > 1 ? selectedFee?.label : null"
:summaryLoading="summaryLoading"
:submitting="submitting"
:submitError="submitError"
@back="onBack"
@submit="submit"
/>
</form>
</template>
</div>
</template>
<!-- Unscoped und mit dem langen Anmeldeprozess geteilt. -->
<style src="../SignUpForm/signupForm.css"></style>
@@ -0,0 +1,44 @@
// 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_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
}
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 state.amountValue > 0 && activeMethods.length > 1
}
return true
}
export function nextVisibleFrom(event, step, state) {
let candidate = step + 1
while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, state)) {
candidate++
}
return candidate
}
export function prevVisibleFrom(event, step, state) {
let candidate = step - 1
while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, state)) {
candidate--
}
return candidate
}
@@ -0,0 +1,177 @@
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'
export function useShortSignupForm(event, participantData) {
const currentStep = ref(SHORT_STEP_PERSON)
const submitting = ref(false)
const summaryLoading = ref(false)
const submitResult = ref(null) // null | { status: 'success'|'exists', participant: {} }
const submitError = ref('')
// Gibt es nur eine Zahlungsart, wird sie vorausgewählt und der Zahlungsschritt entfällt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Bei genau einem teilnehmenden Stamm wird dieser gesetzt, ohne danach zu fragen; erst ab zwei
// Stämmen erscheint das Auswahlfeld.
const localGroups = event.contributingLocalGroups ?? []
const needsLocalGroup = localGroups.length > 1
const formData = reactive({
userId: participantData.id,
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
geburtsdatum: participantData.birthday ?? '',
email_1: participantData.email ?? '',
telefon_1: participantData.phone ?? '',
localGroup: localGroups.length === 1 ? localGroups[0].id : (participantData.localGroup ?? '-1'),
ansprechpartner: '',
email_2: '',
allergien: participantData.allergies ?? '',
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},
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
paymentOptions: {},
summary_information_correct: false,
summary_accept_terms: false,
legal_accepted: false,
payment: false,
})
// Minderjährigkeit steuert Kontaktperson, Erweiterte Erste Hilfe und Badeerlaubnis. Die Grenze von 18 Jahren
// entspricht Age::isfullAged() im Backend.
const isMinor = computed(() => {
if (!formData.geburtsdatum) {
return false
}
return differenceInYears(new Date(), parseISO(formData.geburtsdatum)) < 18
})
// Beitragsstufen mit fertig berechnetem Betrag: [{ value, label, amount, amountValue }]
const feeOptions = ref([])
const amountLoaded = ref(false)
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
}
summaryLoading.value = true
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount')
feeOptions.value = res.data.feeTypes ?? []
amountLoaded.value = true
} 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, flowState.value))
}
const goBack = async () => {
await ensureAmount()
goToStep(prevVisibleFrom(event, currentStep.value, flowState.value))
}
const submit = async () => {
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted) {
return
}
submitting.value = true
submitError.value = ''
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-signup', {
firstname: formData.vorname,
lastname: formData.nachname,
nickname: formData.pfadiname || null,
birthday: formData.geburtsdatum,
email: formData.email_1,
phone: formData.telefon_1,
localGroup: formData.localGroup !== '-1' ? formData.localGroup : null,
contactPerson: isMinor.value ? formData.ansprechpartner : null,
contactEmail: isMinor.value ? formData.email_2 : null,
allergies: formData.allergien || null,
intolerances: formData.intolerances || null,
firstAidPermission: isMinor.value && formData.first_aid !== '-1' ? formData.first_aid : null,
swimmingPermission: isMinor.value && formData.badeerlaubnis !== '-1' ? formData.badeerlaubnis : null,
foto: formData.foto,
participationOptions: formData.participationOptions,
paymentMethod: formData.paymentMethod,
paymentOptions: formData.paymentOptions,
feeType: formData.feeType,
})
submitResult.value = {
status: res.data.status,
participant: res.data.participant,
}
} catch (error) {
submitError.value = error.response?.data?.message
?? 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuche es später erneut.'
} finally {
submitting.value = false
}
}
return {
currentStep,
goToStep,
goNext,
goBack,
formData,
isMinor,
localGroups,
needsLocalGroup,
submit,
submitting,
submitResult,
submitError,
summaryLoading,
summaryAmount,
summaryAmountValue,
feeOptions,
selectedFee,
flowState,
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>
@@ -0,0 +1,218 @@
<script setup>
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
import {reactive} from "vue";
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
localGroups: {type: Array, default: () => []},
needsLocalGroup: Boolean,
})
const emit = defineEmits(['next'])
const errors = reactive({
vorname: '',
nachname: '',
geburtsdatum: '',
localGroup: '',
email_1: '',
telefon_1: '',
ansprechpartner: '',
email_2: '',
first_aid: '',
badeerlaubnis: '',
})
// Nur sichtbare Felder werden geprüft -- Kontaktperson und Erlaubnisse gibt es bei Volljährigen nicht.
const next = () => {
Object.keys(errors).forEach(key => errors[key] = '')
let hasError = false
if (!props.formData.vorname) {
errors.vorname = 'Bitte einen Vornamen angeben.'
hasError = true
}
if (!props.formData.nachname) {
errors.nachname = 'Bitte einen Nachnamen angeben.'
hasError = true
}
if (!props.formData.geburtsdatum) {
errors.geburtsdatum = 'Bitte ein Geburtsdatum angeben.'
hasError = true
}
if (props.needsLocalGroup && (!props.formData.localGroup || props.formData.localGroup === '-1')) {
errors.localGroup = 'Bitte einen Stamm auswählen.'
hasError = true
}
if (!props.formData.email_1) {
errors.email_1 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (!props.formData.telefon_1) {
errors.telefon_1 = 'Bitte eine Telefonnummer angeben.'
hasError = true
}
if (props.isMinor) {
if (!props.formData.ansprechpartner) {
errors.ansprechpartner = 'Bitte eine Kontaktperson angeben.'
hasError = true
}
if (!props.formData.email_2) {
errors.email_2 = 'Bitte eine E-Mail-Adresse der Kontaktperson angeben.'
hasError = true
}
if (props.formData.first_aid === '-1') {
errors.first_aid = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
}
if (hasError) {
return
}
emit('next')
}
</script>
<template>
<div>
<h3>Angaben zur Person</h3>
<table class="form-table">
<tr>
<td>Vorname:</td>
<td>
<input type="text" v-model="formData.vorname" />
<ErrorText :message="errors.vorname" />
</td>
</tr>
<tr>
<td>Pfadiname:</td>
<td>
<input type="text" v-model="formData.pfadiname" />
</td>
</tr>
<tr>
<td>Nachname:</td>
<td>
<input type="text" v-model="formData.nachname" />
<ErrorText :message="errors.nachname" />
</td>
</tr>
<tr>
<td>Geburtsdatum:</td>
<td>
<input type="date" v-model="formData.geburtsdatum" />
<ErrorText :message="errors.geburtsdatum" />
</td>
</tr>
<tr v-if="needsLocalGroup">
<td>Stamm:</td>
<td>
<select v-model="formData.localGroup">
<option value="-1">Bitte wählen</option>
<option v-for="group in localGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
<ErrorText :message="errors.localGroup" />
</td>
</tr>
<tr>
<td>E-Mail:</td>
<td>
<input type="email" v-model="formData.email_1" />
<ErrorText :message="errors.email_1" />
</td>
</tr>
<tr>
<td>Telefon:</td>
<td>
<input type="text" v-model="formData.telefon_1" />
<ErrorText :message="errors.telefon_1" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson (Nachname, Vorname):</td>
<td>
<input type="text" v-model="formData.ansprechpartner" />
<ErrorText :message="errors.ansprechpartner" />
</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>
<input type="email" v-model="formData.email_2" />
<ErrorText :message="errors.email_2" />
</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>
<input type="text" v-model="formData.allergien" />
</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>
<input type="text" v-model="formData.intolerances" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Erweiterte Erste Hilfe erlaubt:</td>
<td>
<select v-model="formData.first_aid">
<option value="-1">Bitte wählen</option>
<option
v-for="firstAidPermission in event.firstAidPermissions"
:key="firstAidPermission.slug"
:value="firstAidPermission.slug">{{ firstAidPermission.name }}</option>
</select><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Nicht dringend-notwendige Erste-Hilfe-Maßnahmen, beinhaltet das Entfernen von Zecken und Splittern sowie das Kleben von Pflastern.
</span>
<ErrorText :message="errors.first_aid" />
</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
<option value="-1">Bitte wählen</option>
<option
v-for="swimmingPermission in event.swimmingPermissions"
:key="swimmingPermission.slug"
:value="swimmingPermission.slug">{{ swimmingPermission.name }}</option>
</select>
<ErrorText :message="errors.badeerlaubnis" />
</td>
</tr>
</template>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter →</button>
</td>
</tr>
</table>
</div>
</template>
@@ -0,0 +1,192 @@
<script setup>
import {computed} from "vue";
const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION'
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
summaryAmount: String,
// Nur gesetzt, wenn zwischen mehreren Beitragsstufen gewählt werden konnte.
feeLabel: {type: String, default: null},
summaryLoading: Boolean,
submitting: Boolean,
submitError: {type: String, default: ''},
})
const emit = defineEmits(['back', 'submit'])
const selectedMethod = computed(() =>
(props.event.paymentMethods ?? []).find(m => m.slug === props.formData.paymentMethod) ?? null
)
// Die Überweisungs-Bestätigung wird nur bei Banküberweisung verlangt.
const requiresPaymentConfirmation = computed(() =>
props.formData.paymentMethod === PAYMENT_ACCOUNT_TRANSACTION
)
// Bestätigungstext ist ein admin-konfigurierbarer Freitext ({amount}-Platzhalter), sonst Default.
const paymentConfirmationText = computed(() => {
const configured = selectedMethod.value?.configuration?.summary_confirmation_text
const template = (configured && String(configured).trim() !== '')
? configured
: 'Ich bestätige, den Betrag von {amount} zu überweisen.'
return template.replaceAll('{amount}', props.summaryAmount ?? '')
})
// Gewählte Teilnahmeoptionen für die Zusammenfassung (Label statt Wert anzeigen).
const selectedParticipationOptions = computed(() =>
(props.event.participationOptions ?? []).map(question => {
const value = props.formData.participationOptions[question.key]
const option = (question.options ?? []).find(o => o.value === value)
return {label: question.title || question.label, value: option?.label ?? ''}
}).filter(entry => entry.value !== '')
)
// Der Stamm steht entweder fest (genau ein teilnehmender Stamm) oder wurde auf Seite 1 gewählt.
const localGroupName = computed(() =>
(props.event.contributingLocalGroups ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
)
const permissionName = (list, slug) =>
(list ?? []).find(entry => entry.slug === slug)?.name ?? '—'
const canSubmit = computed(() =>
props.formData.summary_information_correct
&& props.formData.summary_accept_terms
&& props.formData.legal_accepted
&& (!requiresPaymentConfirmation.value || props.formData.payment)
&& !props.submitting
)
</script>
<template>
<div>
<h3>Zusammenfassung</h3>
<div v-if="summaryLoading" style="color: #6b7280; padding: 20px 0;">Wird geladen…</div>
<div v-else>
<table class="form-table" style="margin-bottom: 20px;">
<tr>
<td>Dein Name:</td>
<td>{{ formData.vorname }} {{ formData.nachname }}</td>
</tr>
<tr v-if="formData.pfadiname">
<td>Pfadiname:</td>
<td>{{ formData.pfadiname }}</td>
</tr>
<tr>
<td>Deine E-Mail:</td>
<td>{{ formData.email_1 }}</td>
</tr>
<tr>
<td>Deine Telefonnummer:</td>
<td>{{ formData.telefon_1 }}</td>
</tr>
<tr v-if="localGroupName">
<td>Stamm:</td>
<td>{{ localGroupName }}</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson:</td>
<td>{{ formData.ansprechpartner }}</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>{{ formData.email_2 }}</td>
</tr>
<tr>
<td>Erweiterte Erste Hilfe:</td>
<td>{{ permissionName(event.firstAidPermissions, formData.first_aid) }}</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>{{ permissionName(event.swimmingPermissions, formData.badeerlaubnis) }}</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>{{ formData.allergien || 'Keine Angabe' }}</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>{{ formData.intolerances || 'Keine Angabe' }}</td>
</tr>
<tr v-for="option in selectedParticipationOptions" :key="option.label">
<td>{{ option.label }}:</td>
<td>{{ option.value }}</td>
</tr>
<tr>
<td>Foto-Erlaubnis:</td>
<td>
<strong>Social Media:</strong> {{ formData.foto.socialmedia ? 'Ja' : 'Nein' }},
<strong>Printmedien:</strong> {{ formData.foto.print ? 'Ja' : 'Nein' }},
<strong>Webseite:</strong> {{ formData.foto.webseite ? 'Ja' : 'Nein' }},
<strong>Partnerorganisationen:</strong> {{ formData.foto.partner ? 'Ja' : 'Nein' }},
<strong>Interne Zwecke:</strong> {{ formData.foto.intern ? 'Ja' : 'Nein' }}
</td>
</tr>
<tr><td>Veranstaltung:</td><td><strong>{{ event.name }}</strong></td></tr>
<tr><td>Zeitraum:</td><td>{{ event.eventBegin }} – {{ event.eventEnd }}</td></tr>
</table>
<h4 style="margin: 0 0 8px 0;">Kostenübersicht</h4>
<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">
<td><strong>Teilnahmebeitrag:</strong></td>
<td><strong>{{ summaryAmount }}</strong></td>
</tr>
</table>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_information_correct" />
Ich bestätige, dass alle Angaben korrekt sind.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_accept_terms" />
Ich akzeptiere die Teilnahmebedingungen.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.legal_accepted" />
Ich stimme der Datenschutzerklärung zu.
</label>
<label v-if="requiresPaymentConfirmation" style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.payment" />
{{ paymentConfirmationText }}
</label>
</div>
<p v-if="submitError" style="color: #991b1b; font-weight: 600;">{{ submitError }}</p>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back')">← Zurück</button>
<button
type="submit"
class="btn-primary"
:disabled="!canSubmit"
style="background: #059669;"
>
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.cost-table .cost-total td {
border-top: 1px solid #d1d5db;
padding-top: 8px;
}
</style>
@@ -122,170 +122,5 @@ const steps = [
</div>
</template>
<style>
/* ─── Progress (Step-Pills) ─── */
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"],
.form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select,
.form-table textarea {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary {
padding: 8px 20px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
padding: 8px 20px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
</style>
<!-- Unscoped und mit der Kurzanmeldung geteilt (ShortSignupForm.vue bindet dieselbe Datei ein). -->
<style src="./signupForm.css"></style>
@@ -0,0 +1,167 @@
/* Geteilte Styles der Anmelde-Wizards (langer Prozess und Kurzanmeldung). */
/* ─── Progress (Step-Pills) ─── */
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"],
.form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select,
.form-table textarea {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary {
padding: 8px 20px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
padding: 8px 20px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
@@ -42,7 +42,7 @@ const next = () => {
hasError = true
}
if (props.formData.badeerlaubnis === '-1') {
if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
hasError = true
}
@@ -86,7 +86,7 @@ const next = () => {
<ErrorText :message="errors.email_2" />
</td>
</tr>
<tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
+9 -3
View File
@@ -3,6 +3,7 @@
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import SignupForm from './Partials/SignUpForm/SignupForm.vue'
import ShortSignupForm from './Partials/ShortSignUpForm/ShortSignupForm.vue'
import FullScreenModal from "../../../Views/Components/FullScreenModal.vue";
import AvailableEvents from "./Partials/AvailableEvents.vue";
import {ref} from "vue";
@@ -37,7 +38,7 @@ function close() {
</span>
</div>
<p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }}
📍 {{ props.event.fullAddress }}
</p>
</div>
<div v-else class="signup-header signup-header--closed">
@@ -48,7 +49,7 @@ function close() {
</span>
</div>
<p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }}
📍 {{ props.event.fullAddress }}
</p>
</div>
@@ -98,8 +99,13 @@ function close() {
<hr class="signup-divider" />
<div class="signup-body">
<ShortSignupForm
v-if="props.event.registrationAllowed && props.event.shortRegistration"
:event="props.event"
:participantData="props.participantData ?? {}"
/>
<SignupForm
v-if="props.event.registrationAllowed"
v-else-if="props.event.registrationAllowed"
:event="props.event"
:participantData="props.participantData ?? {}"
:localGroups="props.localGroups ?? []"
@@ -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;
if (null !== $request->file('receipt')) {
$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
* 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
* Bedingung.
* bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund.
*/
const travellers = ref(data.userName || '');
const travellersComplete = computed(() => travellers.value.trim() !== '');
const have_receipt = ref('')
const amount = ref(0.00);
const invoiceType = ref(null);
@@ -121,7 +122,7 @@ function handleFileChange(event) {
/>
</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>
<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" />
@@ -129,7 +130,8 @@ function handleFileChange(event) {
<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>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
@@ -161,7 +163,7 @@ function handleFileChange(event) {
/>
</fieldset>
<fieldset v-else-if="distanceAllowance != null">
<fieldset v-else-if="travellersComplete && distanceAllowance != null">
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
Gesamtlänge des Reisewegs:
<NumericInput
@@ -61,8 +61,16 @@ class AcceptRefundCommand
return $response;
}
$owner = trim($this->request->accountOwner);
$iban = Iban::normalize($this->request->accountIban);
// Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
// Freigabe festgeschrieben. Dann gilt es -- und die Felder aus dem Request werden verworfen.
//
// Das ist der eigentliche Gewinn der ganzen Übung: Der Teili tippt keine IBAN mehr ab, also
// kann er sich weder vertippen noch lässt sich über einen direkten Aufruf dieser Route eine
// fremde IBAN unterschieben. Erstattet wird auf das Konto, von dem gezahlt wurde, Punkt.
$accountIsKnown = !$this->request->donation && filled($refund->account_iban);
$owner = $accountIsKnown ? (string) $refund->account_owner : trim($this->request->accountOwner);
$iban = $accountIsKnown ? (string) $refund->account_iban : Iban::normalize($this->request->accountIban);
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
@@ -83,6 +91,10 @@ class AcceptRefundCommand
. 'von dem der Beitrag gezahlt wurde.';
}
// Nur was der Teili selbst eingibt, muss geprüft werden. Das bekannte Konto hat die
// Prüfung schon beim Import bestanden -- dort wird eine ungültige IBAN gar nicht erst
// übernommen.
if (!$accountIsKnown) {
if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
}
@@ -93,6 +105,7 @@ class AcceptRefundCommand
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
}
}
}
if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.';
@@ -8,8 +8,15 @@ class AcceptRefundRequest
{
public function __construct(
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
public readonly string $accountIban,
/**
* Die Eingaben des Teilis -- leer, wenn es nichts einzugeben gab.
*
* Das ist der Normalfall bei einer Spende (kein Konto nötig) und bei einem bereits bekannten
* Konto: Dort steht die Bankverbindung seit der Freigabe am Vorgang, und der Command nimmt
* ausschließlich diese.
*/
public readonly string $accountOwner = '',
public readonly string $accountIban = '',
/**
* Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts.
*
@@ -2,6 +2,7 @@
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Enumerations\RefundAccountSource;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
@@ -30,6 +31,12 @@ class CreateRefundDocumentCommand
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
/**
* Ihre Fassung für Zahlungsarten ohne Ursprungskonto (Barzahlung): Dort gab es kein Konto, von dem
* der Beitrag kam -- erklärt wird stattdessen, dass das angegebene auf den eigenen Namen läuft.
*/
public const string OWN_ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
@@ -152,6 +159,18 @@ class CreateRefundDocumentCommand
);
}
/**
* Die Herkunft des Erstattungskontos, abgeleitet aus der Zahlungsart der Anmeldung.
*
* Der Beleg wird bei jedem Abruf neu gerendert und nicht gespeichert. Änderte jemand nachträglich
* die Zahlungsart, zeigte ein Nachdruck die jeweils andere Kontoerklärung. Praktisch passiert das
* nicht -- eine eigene Spalte am Vorgang wäre dafür unverhältnismäßig.
*/
private function accountSource(): RefundAccountSource
{
return $this->participant->refundData()->source;
}
private function declarationText(): string
{
if ($this->request->donation) {
@@ -164,13 +183,21 @@ class CreateRefundDocumentCommand
// Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
// gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
//
// Welche der beiden Kontoerklärungen gilt, sagt die Zahlungsart: Wer bar gezahlt hat, kann
// nicht bestätigen, dass das Konto dasselbe ist -- es gab keines.
$accountSource = $this->accountSource();
return $this->pageText(
self::DECLARATION_TEXT,
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.'
) . '<br /><br />' . $this->pageText(
self::ACCOUNT_DECLARATION_TEXT,
'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
$accountSource->accountDeclarationText(),
$accountSource === RefundAccountSource::None
? 'Ich bestätige, dass das angegebene Konto auf meinen Namen läuft oder ich über dieses '
. 'Konto verfügungsberechtigt bin.'
: 'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
. 'gezahlt wurde.'
);
}
@@ -6,6 +6,7 @@ use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
@@ -29,6 +30,8 @@ class ReleaseRefundCommand
private ParticipantRefundRepository $refunds;
private ?GetRefundDataResponse $knownRefundData = null;
public function __construct(private readonly ReleaseRefundRequest $request)
{
$this->participant = $request->participant;
@@ -63,6 +66,10 @@ class ReleaseRefundCommand
'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(),
// Kennt die Zahlungsart das Konto, von dem der Beitrag kam, steht es von Anfang an
// fest. Der Teili entscheidet dann nur noch: auszahlen oder spenden.
'account_owner' => $this->knownAccountOwner(),
'account_iban' => $this->knownAccountIban(),
'released_by' => currentUser()?->id,
'released_at' => now(),
]);
@@ -89,6 +96,31 @@ class ReleaseRefundCommand
return $response;
}
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Lazy und einmalig, weil der Aufruf über die Event-Relation des Teilnehmers läuft
* ({@see EventParticipant::paymentConfiguration()}) und in einem Vorgang mehrfach gebraucht wird.
*/
private function knownRefundData(): GetRefundDataResponse
{
return $this->knownRefundData ??= $this->participant->refundData();
}
/**
* Bei der Sofort-Einreichung und bei der Spende bleibt das Feld leer: Dort setzt der
* {@see AcceptRefundCommand} es -- aus der Eingabe der Aktionsleitung bzw. auf `null`.
*/
private function knownAccountOwner(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountOwner);
}
private function knownAccountIban(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountIban);
}
/**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
*
@@ -3,16 +3,20 @@
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Enumerations\RefundAccountSource;
use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use App\Support\Iban;
use Inertia\Response;
/**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
*
* Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert
* Liefert ausschließlich Anzeigedaten -- niemals die vollständige Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
* Ist das Konto aus dem Zahlungseingang bekannt, geht es deshalb **maskiert** hinaus: genug zum
* Wiedererkennen, zu wenig zum Mitschreiben.
*/
class RefundPageController extends CommonController
{
@@ -58,6 +62,26 @@ class RefundPageController extends CommonController
]);
}
return array_merge($common, ['state' => 'open']);
// Woher das Erstattungskonto kommt, entscheidet die Zahlungsart -- und damit, was die Seite
// fragt: gar nichts (Konto liegt vor), die Herkunftsfrage (es gab ein Ursprungskonto) oder nur
// die Felder (Barzahlung, es gab nie eines).
$accountSource = $refund->account_iban !== null
? RefundAccountSource::Known
: $participant->refundData()->source;
return array_merge($common, [
'state' => 'open',
'accountSource' => $accountSource->value,
// Der Name des page_texts-Eintrags, nicht der Text: Welche Erklärung gilt, entscheidet der
// Server -- sonst könnten Seite und Beleg auseinanderlaufen.
'accountDeclarationText' => $accountSource->accountDeclarationText(),
// Ist das Konto bekannt (aus dem Zahlungseingang), braucht der Teili es nicht einzugeben --
// er soll es aber wiedererkennen können, um einen Fehler zu melden. Deshalb maskiert:
// Die vollständige Bankverbindung verlässt diese Seite weiterhin nicht.
'knownAccount' => $refund->account_iban === null ? null : [
'owner' => $refund->account_owner,
'ibanMasked' => Iban::mask($refund->account_iban),
],
]);
}
}
@@ -28,8 +28,35 @@ const props = defineProps({
reasonNote: String,
acceptedAt: String,
donation: Boolean,
/*
* Das Konto aus dem Zahlungseingang, falls bekannt: {owner, ibanMasked}.
*
* Ist es gesetzt, wird es nicht mehr abgefragt -- es steht bereits am Vorgang und der Server
* nimmt ohnehin nur dieses. Die IBAN kommt maskiert an: zum Wiedererkennen genug, zum
* Mitschreiben zu wenig.
*/
knownAccount: {type: Object, default: null},
/*
* Woher das Erstattungskonto kommt -- entschieden von der Zahlungsart, nicht hier:
* known -- liegt vor, wird nur bestätigt
* origin -- es gab ein Ursprungskonto, wir fragen danach
* none -- es gab nie eines (Barzahlung); dann ist die Herkunftsfrage sinnlos
*/
accountSource: {type: String, default: 'origin'},
/** Name des page_texts-Eintrags mit der zweiten Erklärung -- der Server wählt ihn. */
accountDeclarationText: {type: String, default: 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'},
})
const hasKnownAccount = computed(() => props.accountSource === 'known')
/**
* Ob nach dem Ursprungskonto gefragt wird.
*
* Nur wenn es eines gab: Wer bar gezahlt hat, könnte die Frage nicht sinnvoll beantworten -- und landete
* bei „Nein" in einer Sackgasse, die ihn auffordert, das Konto zu nennen, von dem überwiesen wurde.
*/
const asksForOrigin = computed(() => props.accountSource === 'origin')
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
@@ -70,6 +97,32 @@ const accountComplete = computed(
const isDonation = computed(() => decision.value === 'donation')
/**
* Wann die Erklärungen des Auszahlungswegs erscheinen.
*
* Bekanntes Konto: sofort -- es steht ja schon da. Sonst erst, wenn die Kontoangaben vollständig sind;
* wo nach dem Ursprungskonto gefragt wird, zusätzlich erst nach einem „Ja".
*/
const showDeclarations = computed(() => {
if (decision.value !== 'payout') return false
if (hasKnownAccount.value) return true
if (!accountComplete.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wann nach Kontoinhaber*in und IBAN gefragt wird.
*
* Überall dort, wo das Konto nicht schon feststeht -- beim Ursprungskonto-Weg allerdings erst, nachdem
* die Herkunftsfrage bejaht wurde: Sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
*/
const showAccountFields = computed(() => {
if (decision.value !== 'payout' || hasKnownAccount.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
@@ -95,8 +148,13 @@ function validate() {
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) {
// Beim bekannten Konto gibt es nichts einzugeben und damit auch nichts zu prüfen -- nur die
// Bestätigung, dass es stimmt.
if (!hasKnownAccount.value) {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
}
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
@@ -111,8 +169,16 @@ async function submit() {
saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
// Beim bekannten Konto gehen ebenfalls keine Kontofelder mit: Der Server nimmt ausschließlich,
// was am Vorgang steht, alles Mitgeschickte würde er verwerfen.
const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted}
: hasKnownAccount.value
? {
donation: false,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
: {
donation: false,
accountOwner: form.accountOwner,
@@ -262,6 +328,44 @@ async function submit() {
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
-->
<template v-else-if="decision === 'payout'">
<!--
Konto bekannt: Es steht schon am Vorgang und stammt aus dem
Zahlungseingang. Die Frage danach erübrigt sich, eingegeben wird nichts
mehr -- gezeigt wird es trotzdem, damit ein falsch zugeordneter
Zahlungseingang hier auffällt und nicht erst beim Rückläufer der Bank.
-->
<template v-if="hasKnownAccount">
<h3>Wir erstatten auf das Konto, von dem dein Beitrag kam</h3>
<div class="known-account">
<span class="known-account__owner">{{ knownAccount.owner }}</span>
<span class="known-account__iban">{{ knownAccount.ibanMasked }}</span>
</div>
<p class="hint">
Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das
Konto nicht zu deiner Zahlung, wende dich bitte an die Aktionsleitung:
{{ props.eventEmail }}
</p>
</template>
<!--
Kein Ursprungskonto (bar gezahlt): Die Herkunftsfrage entfällt. Sonst
landete der Teili bei „Nein" in einer Sackgasse, die ihn nach dem Konto
fragt, von dem überwiesen wurde -- es gab keines. Erstattet wird auf ein
Konto, das ihm gehört.
-->
<template v-else-if="!asksForOrigin">
<h3>Auf welches Konto sollen wir erstatten?</h3>
<p class="choice-hint">
Deinen Beitrag hast du nicht überwiesen, deshalb brauchen wir eine
Bankverbindung von dir. Bitte gib ein Konto an, das auf deinen Namen
läuft oder über das du verfügen darfst.
</p>
</template>
<template v-else>
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
@@ -294,7 +398,14 @@ async function submit() {
</button>
</template>
<template v-else-if="sameAccount === true">
</template>
<!--
Die Kontoangaben -- gleich in beiden Wegen, in denen sie erfragt werden:
nach einem „Ja" auf die Herkunftsfrage und bei einer Zahlungsart ohne
Ursprungskonto. Nur beim bereits bekannten Konto entfallen sie.
-->
<template v-if="showAccountFields">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
@@ -312,13 +423,17 @@ async function submit() {
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
</template>
<!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat.
In beiden Wegen dieselben: Beim bekannten Konto bestätigt der Teili
Angezeigtes statt Getipptes -- der Beleg bleibt dadurch unverändert.
-->
<template v-if="accountComplete">
<template v-if="showDeclarations">
<div class="declaration">
<input
id="refund-declaration"
@@ -339,7 +454,7 @@ async function submit() {
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT"
:text-name="accountDeclarationText"
belongs-to="refund-account-declaration"
/>
</div>
@@ -357,7 +472,6 @@ async function submit() {
</button>
</template>
</template>
</template>
</form>
</template>
</template>
@@ -429,6 +543,31 @@ h3 {
font-weight: bold;
}
/* Das bekannte Konto -- Anzeige, kein Feld. Bewusst wie ein Beleg gesetzt und nicht wie ein Formular,
damit gar nicht erst der Eindruck entsteht, hier ließe sich etwas ändern. */
.known-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
margin-bottom: 4px;
}
.known-account__owner {
font-weight: bold;
color: #374151;
}
.known-account__iban {
font-family: monospace;
font-size: 1.05rem;
letter-spacing: 0.05em;
color: #4b5563;
}
.field {
margin-bottom: 16px;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Enumerations;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
/**
* Woher das Konto kommt, auf das eine Erstattung geht -- die Antwort hängt an der Zahlungsart.
*
* Die Unterscheidung trägt eine Kontrolle: Erstattet wird auf das Konto, von dem der Beitrag kam, damit
* sich über eine Erstattung kein Geld auf ein fremdes Konto umleiten lässt. Wo es nie ein solches Konto
* gab (Barzahlung), kann diese Kontrolle nicht greifen -- dort tritt an ihre Stelle die Erklärung, dass
* das angegebene Konto auf den eigenen Namen läuft.
*
* Reines Code-Enum wie {@see VatPricingMode}: Es wird zur Laufzeit aus der Zahlungsart abgeleitet und
* nirgends gespeichert.
*/
enum RefundAccountSource: string
{
/** Das Konto liegt vor -- aus dem Zahlungseingang übernommen. Es wird nicht mehr erfragt. */
case Known = 'known';
/** Es gab ein Ursprungskonto, wir kennen es nicht. Der Teili gibt es an und bestätigt die Herkunft. */
case Origin = 'origin';
/** Es gab nie ein Konto (Barzahlung). Der Teili gibt eines an, das auf seinen Namen läuft. */
case None = 'none';
/** Ob die Kontoangaben vom Teili erfragt werden müssen. */
public function needsInput(): bool
{
return $this !== self::Known;
}
/**
* Der `page_texts`-Eintrag mit der zweiten Erklärung des Auszahlungswegs.
*
* Hier und nicht in Seite bzw. Beleg getrennt, damit beide denselben Wortlaut zeigen: Was der Teili
* gelesen hat, muss wörtlich das sein, was das PDF ihm zuschreibt.
*/
public function accountDeclarationText(): string
{
return $this === self::None
? CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT
: CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT;
}
}
@@ -7,6 +7,9 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -69,6 +72,37 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getOptions(), $config);
}
/**
* Optionen mit `scope => 'tenant'`: Sie gelten für den ganzen Mandanten und werden **nicht** pro
* Veranstaltung eingefroren.
*
* Beispiel ist das Kontoauszug-Format: Es beschreibt die Bank, nicht die Zusage an die
* Teilnehmenden. Läge es im Event-Snapshot, ließe sich nach einem Bankwechsel für laufende
* Aktionen nichts mehr importieren, bis das Format bei jeder einzeln nachgezogen wurde.
* IBAN und Kontoinhaber bleiben dagegen weiterhin pro Aktion eingefroren.
*
* @return array<int, string>
*/
public function tenantScopedOptionKeys(): array
{
return array_values(array_map(
static fn (array $option) => $option['name'],
array_filter($this->getOptions(), static fn (array $option) => ($option['scope'] ?? null) === 'tenant')
));
}
/**
* Entfernt die tenant-weiten Optionen aus einer Konfiguration -- angewandt auf den
* Copy-on-Assign-Snapshot einer Veranstaltung.
*
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
public function stripTenantScopedOptions(array $config): array
{
return array_diff_key($config, array_flip($this->tenantScopedOptionKeys()));
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
@@ -84,6 +118,24 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getParticipantOptions(), $input);
}
/**
* Die Teilnehmer-Optionen, die im Anmeldeformular tatsächlich abgefragt werden.
*
* Optionen mit `system => true` fallen heraus: Sie liegen zwar in `payment_options`, entstehen aber
* im Programm statt durch eine Eingabe -- das Zahler-Konto etwa trägt der Import aus dem
* Kontoauszug nach. Im Schema müssen sie trotzdem stehen, sonst verwirft
* {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
*
* @return array<int, array{name: string, label: string, type: string, required: bool}>
*/
public function participantInputOptions(): array
{
return array_values(array_filter(
$this->getParticipantOptions(),
static fn (array $option): bool => ($option['system'] ?? false) !== true,
));
}
/**
* @param array<int, array{name: string, required?: bool}> $schema
* @return array<int, string>
@@ -143,6 +195,19 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return new RegistrationSummaryResponse();
}
/**
* Standard: Es gab ein Ursprungskonto, wir kennen es nur nicht ({@see RefundAccountSource::Origin}).
*
* Bewusst die strengere Annahme. Der Teili wird dann gefragt, ob es dasselbe Konto ist, von dem der
* Beitrag kam -- die Kontrolle, die verhindert, dass sich über eine Erstattung Geld auf ein fremdes
* Konto umleiten lässt. Eine Zahlungsart ohne Ursprungskonto (Barzahlung) muss das ausdrücklich
* sagen; stillschweigend die Kontrolle fallen zu lassen wäre die falsche Vorgabe.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
return new GetRefundDataResponse();
}
public function doPayment(DoPaymentRequest $request): DoPaymentResponse
{
// TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen).
+72 -6
View File
@@ -7,15 +7,16 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
- `EventPaymentModule` — **Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
`createInvoice()`.
`createInvoice()`, `getRefundData()`.
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges).
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
`GetRefundData*`, `TransactionMatch`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode` — **Fähigkeits-Interface** (siehe unten).
- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements` — **Fähigkeits-Interfaces** (siehe unten).
## Kernregeln
@@ -32,6 +33,15 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben.
- Optionaler Schlüssel `'hint'` je Option: erklärender Hilfetext, den die Admin-Render-Stellen unter dem Feld anzeigen
(z.B. bei `payment_information`, dass der Text am Anmeldeende + in der Mail erscheint).
- Optionaler Schlüssel `'scope' => 'tenant'` (nur `getOptions()`): Die Option gilt für den **ganzen Mandanten** und
wird **nicht** pro Event eingefroren. Abgeleitet über `tenantScopedOptionKeys()` /
`stripTenantScopedOptions()`, angewandt beim Copy-on-Assign in `SetPaymentMethodsCommand` und ausgefiltert in
`ParticipationFees.vue`. Einziger Fall: `statement_ruleset` (s.u.).
- Optionaler Schlüssel `'system' => true` (nur `getParticipantOptions()`): Das Feld liegt zwar in
`payment_options`, wird aber **nicht im Anmeldeformular abgefragt** — es entsteht im Programm. Gefiltert wird
serverseitig in `AbstractEventPaymentModule::participantInputOptions()`, an das `PaymentMethod::
participantOptionsFor()` (und damit die Resource/das Frontend) delegiert. Im Schema müssen die Felder trotzdem
stehen, sonst verwirft `sanitizeParticipantOptions()` sie als unbekannte Schlüssel.
- `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`.
- **Aktivierungs-Guard:** Eine Tenant-Zahlungsmethode darf nur `active` werden, wenn alle `required`-Optionen befüllt
@@ -55,6 +65,32 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.
- `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul.
- **`getRefundData()` — „auf welches Konto wäre zu erstatten, und woher kommt es?"** Steht im **Kern-Interface**, weil
jede Zahlungsart eine Antwort darauf hat; sie fällt nur unterschiedlich aus. Geantwortet wird mit
`App\Enumerations\RefundAccountSource` (reines Code-Enum, nirgends gespeichert):
- `Known` — das Konto liegt vor. Nur die Überweisung liefert das, aus `payment_options`
(`payer_iban`/`payer_account_owner`, vom Kontoauszug-Import hinterlegt) und **nur bei gültiger Prüfziffer**; eine
ungültige IBAN würde ungeprüft übernommen. `event_participants.refund_data` ist demgegenüber nur ein
**abgeleitetes Kennzeichen** für Listen und Abfragen, nie die Quelle — zwei Quellen für dieselbe Wahrheit driften
auseinander.
- `Origin` — es gab ein Ursprungskonto, wir kennen es nicht. **Vorgabe der Basisklasse**, bewusst die strengere
Annahme: Der Teili wird gefragt, ob es dasselbe Konto ist, und bestätigt die Herkunft. Das ist die Kontrolle
gegen das Umleiten einer Erstattung auf ein fremdes Konto; sie stillschweigend fallen zu lassen wäre die falsche
Vorgabe für ein künftiges Modul.
- `None` — es gab **nie** eines (`UndefinedPaymentModule`, Barzahlung). Herkunftsfrage und Herkunfts-Erklärung
wären sinnlos bzw. unwahr; an ihre Stelle tritt `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN` („läuft auf meinen
Namen"). Welcher `page_texts`-Eintrag gilt, sagt `RefundAccountSource::accountDeclarationText()` — eine Quelle
für Seite **und** Beleg.
Aufgelöst wird überall über `EventParticipant::refundData()`; verwertet in `ReleaseRefundCommand` (schreibt ein
bekanntes Konto direkt an den Vorgang, der aber `pending` bleibt — der Teili entscheidet noch über Auszahlung oder
Spende), in `AcceptRefundCommand` (ein gesetztes Konto lässt sich **nicht** aus dem Request überschreiben), im
`RefundPageController` und im Erstattungsbeleg. Auf der Token-Seite und in der Freigabe-Mail geht eine bekannte IBAN
nur maskiert hinaus (`Iban::mask()`).
- **Keine Barauszahlung.** Auch wer bar gezahlt hat, bekommt überwiesen. Rechtlich spricht nichts dagegen — das GwG
gilt für den Verband nicht (§ 2 Abs. 1 GwG; kein Güterhändler nach § 1 Abs. 9), und eine Regel „bar rein, bar raus"
existiert nicht. Die Überweisung ist zudem besser belegt: Der Kontoauszug beweist die Zahlung, während eine
Barauszahlung an einer Unterschrift hinge und die Barkasse nach § 146 AO kassensturzfähig zu halten wäre. Wer doch
bar auszahlt, bucht das über die normale Auslagenerfassung.
## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation)
@@ -77,6 +113,31 @@ Beispiel GiroCode (nur Überweisung):
- Künftige Verfahren würden analog eigene Fähigkeiten mitbringen (z.B. `ProvidesRedirect` für PayPal,
`ProvidesMandate` für SEPA-Lastschrift) — **erst modellieren, wenn tatsächlich gebraucht.**
### Kontoauszug-Import (zwei Interfaces, bewusst getrennt)
- `ProvidesStatementRuleset::statementRuleset(array $configuration): BankStatementRuleset` — „dieses Modul pflegt das
CSV-Format der Bank". Implementiert **nur** von `AccountTransferPaymentModule`. Das Format ist eine Eigenschaft der
**Bank**, nicht der Zahlungsart: eine Bank, ein Export, ein Ruleset. Das kommende Lastschrift-Modul liest denselben
Auszug und implementiert dieses Interface **nicht** — sonst wäre dasselbe Format zweimal zu pflegen und nach dem
nächsten Bankwechsel eine der beiden Stellen vergessen.
- `ReadsBankStatements` — „dieses Modul kann Umsätze verwerten": `isRelevantTransaction()` (Überweisung: Gutschriften;
Lastschrift später: Belastungen und Rücklastschriften), `matchTransaction()` (Überweisung: Verwendungszweck, Namen,
bekannte Zahler-IBAN, Betrag; Lastschrift später: Mandatsreferenz), `recordTransaction()` (Überweisung: Zahler-Konto
in `payment_options` + `refund_data`). Nur diese drei Entscheidungen sind zahlartspezifisch.
- **Ablage des Rulesets:** App-Standard in `config/bankStatement.php` (GLS Gemeinschaftsbank), Tenant-Override in der
Modul-Option `statement_ruleset` (`type: 'bank-ruleset'`, `scope: 'tenant'`). Der Override gilt **ganz oder gar
nicht** — kein feldweiser Merge, sonst bekäme man beim Umstellen des Trennzeichens weiterhin die Spaltennamen der
GLS untergeschoben.
- **Kandidaten schließen Abgemeldete ein** (`EventParticipantRepository::getForPaymentMatching()`): Wer den Beitrag
überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Die Zahlung wird erfasst — erst dann gibt
es etwas zu erstatten. Die Prüfansicht weist die Abmeldung aus (`isSignedOff`/`signedOffAt`).
- **Modul-Schicht bleibt DB-frei:** Die Konfiguration wird hereingereicht, die Kandidaten für `matchTransaction()`
ebenfalls. Geholt wird beides vom `PaymentMethodRepository` bzw. `EventParticipantRepository`, verdrahtet in den
Actions `ParseBankStatement` / `BookBankStatementPayments` (Domain `Event`). `doPayment()` ist **nicht** beteiligt:
das stößt eine Zahlung an, hier wird eine bereits erfolgte nachgetragen.
- Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
dieser Schicht — sie sind zahlartneutral.
## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -118,6 +179,11 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5):
`docker exec mareike-mareike-app-1 php artisan test`.
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
Ausführung im Container (PHP 8.5). `php artisan test` läuft im 128-MB-Limit auf `config/postCode.php` in einen
Speicherfehler, deshalb direkt über PHPUnit mit angehobenem Limit:
`docker exec mareike-mareike-app-1 php -d memory_limit=1G vendor/bin/phpunit`
@@ -0,0 +1,23 @@
<?php
namespace App\EventPaymentModules\DTO;
use App\Models\EventParticipant;
/**
* Eingabe für {@see \App\EventPaymentModules\EventPaymentModule::getRefundData()}.
*
* Wie überall in dieser Schicht wird die Konfiguration hereingereicht, statt sie selbst zu holen --
* die Module bleiben damit frei von Datenbankzugriffen und ohne DB testbar.
*/
final class GetRefundDataRequest
{
/**
* @param array<string, mixed> $configuration aufgelöste Konfiguration des Zahlungsmoduls
*/
public function __construct(
public readonly EventParticipant $participant,
public readonly array $configuration = [],
) {
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\EventPaymentModules\DTO;
use App\Enumerations\RefundAccountSource;
/**
* Auf welches Konto ist zu erstatten -- und woher kommt es?
*
* `source` ist die eigentliche Auskunft: Kennen wir das Konto bereits (`Known`), gab es eines, das wir
* erfragen müssen (`Origin`), oder gab es nie eines (`None`, Barzahlung)? Davon hängt ab, was die
* Erstattungsseite fragt und welche Erklärung der Teili unterschreibt.
*
* Konto und Inhaber sind nur bei `Known` gefüllt -- und dann auch nur, wenn beide vorliegen und die
* IBAN die Prüfziffer besteht. Eine ungültige IBAN würde ungeprüft übernommen und das Geld ginge im
* Zweifel an eine fremde Person; lieber wie bisher nachfragen.
*/
final class GetRefundDataResponse
{
public RefundAccountSource $source = RefundAccountSource::Origin;
public ?string $accountOwner = null;
public ?string $accountIban = null;
/** Woher die Angaben stammen -- Klartext für die Anzeige in der Aktionsleitung. */
public ?string $sourceNote = null;
/** Kurzform für „das Konto steht fest": nur dann sind accountOwner/accountIban gefüllt. */
public function hasAccount(): bool
{
return $this->source === RefundAccountSource::Known;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\EventPaymentModules\DTO;
use App\Models\EventParticipant;
/**
* Ergebnis von {@see \App\EventPaymentModules\ReadsBankStatements::matchTransaction()}:
* die erkannte Anmeldung samt Einschätzung, wie belastbar die Erkennung ist.
*
* Die Einschätzung geht in die Prüfansicht und ist dort der Unterschied zwischen „kann man
* durchwinken" und „bitte einmal ansehen". Ein Vorschlag ersetzt nie die Bestätigung durch einen
* Menschen -- gebucht wird nur, was in der Prüfansicht stehen geblieben ist.
*/
final class TransactionMatch
{
public const string CONFIDENCE_CERTAIN = 'sicher';
public const string CONFIDENCE_UNCERTAIN = 'unsicher';
public function __construct(
public readonly EventParticipant $participant,
public readonly string $confidence = self::CONFIDENCE_CERTAIN,
) {
}
}
@@ -6,6 +6,8 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -68,4 +70,16 @@ interface EventPaymentModule
* (In dieser Iteration nur als Stub vorhanden.)
*/
public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse;
/**
* Auf welches Konto wäre zu erstatten -- und wissen wir es überhaupt?
*
* Gehört ins Kern-Interface und nicht in ein Fähigkeits-Interface, weil jede Zahlungsart eine
* Antwort darauf hat; sie fällt nur unterschiedlich aus. Barzahlung: keine. Überweisung: das
* Konto, von dem der Beitrag kam. SEPA-Lastschrift später: das Konto des Mandats.
*
* Standard ist „nicht bekannt" -- dann läuft die Erstattung wie gehabt über die Angaben des
* Teilis bzw. der Aktionsleitung.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse;
}
@@ -2,21 +2,42 @@
namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\ProvidesGiroCode;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider;
use App\Support\Iban;
use App\ValueObjects\Amount;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
use Illuminate\Support\Collection;
/**
* Überweisung auf das Veranstaltungskonto -- die Zahlung erfolgt manuell durch die teilnehmende Person.
*/
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements ProvidesGiroCode
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
ProvidesGiroCode,
ProvidesStatementRuleset,
ReadsBankStatements
{
/** Admin-Option, unter der das Kontoauszug-Format des Mandanten liegt. */
public const string OPTION_STATEMENT_RULESET = 'statement_ruleset';
/** Teilnehmer-Optionen, die der Zahlungsimport nachträgt (nicht im Anmeldeformular). */
public const string OPTION_PAYER_ACCOUNT_OWNER = 'payer_account_owner';
public const string OPTION_PAYER_IBAN = 'payer_iban';
public static function slug(): string
{
return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
@@ -40,18 +61,263 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false],
['name' => 'summary_confirmation_text', 'label' => 'Bestätigung durch Teilnehmende (Zusammenfassung, {amount} als Platzhalter)', 'type' => 'string', 'required' => false],
['name' => 'icon', 'label' => 'Symbol', 'type' => 'icon', 'required' => false],
[
'name' => self::OPTION_STATEMENT_RULESET,
'label' => 'Format des Kontoauszugs (CSV)',
'type' => 'bank-ruleset',
'required' => false,
'scope' => 'tenant',
'hint' => 'Nur ausfüllen, wenn eure Bank ein anderes Format liefert als der App-Standard. '
. 'Die Einstellung gilt für alle Aktionen -- auch für bereits laufende.',
],
];
}
/**
* Beim Zahlungsimport nachgetragen, nicht beim Anmelden abgefragt: `system => true` blendet die
* Felder im Anmeldeformular aus. Im Schema müssen sie trotzdem stehen, sonst verwirft
* {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
*/
public function getParticipantOptions(): array
{
return [
[
'name' => self::OPTION_PAYER_ACCOUNT_OWNER,
'label' => 'Kontoinhaber*in der Zahlung',
'type' => 'string',
'required' => false,
'system' => true,
],
[
'name' => self::OPTION_PAYER_IBAN,
'label' => 'IBAN der Zahlung',
'type' => 'string',
'required' => false,
'system' => true,
],
];
}
public function statementRuleset(array $configuration): BankStatementRuleset
{
$override = $configuration[self::OPTION_STATEMENT_RULESET] ?? null;
// Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular.
if (is_string($override)) {
$decoded = json_decode($override, true);
$override = is_array($decoded) ? $decoded : null;
}
return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null);
}
/**
* Erstattet wird auf das Konto, von dem der Beitrag kam -- und genau das steht seit dem
* Zahlungsimport in den Teilnehmer-Optionen.
*
* Gelesen wird ausschließlich aus `payment_options`, nicht aus dem Kennzeichen `refund_data`:
* Das ist ein abgeleitetes Merkmal für Listen und Abfragen. Zwei Quellen für dieselbe Wahrheit
* driften früher oder später auseinander, und die falsche gewänne dann eine Auszahlung.
*
* Eine IBAN, die die Prüfziffer nicht besteht, wird nicht gemeldet: Sie würde ungeprüft
* übernommen und das Geld ginge im Zweifel an eine fremde Person. Lieber wie bisher nachfragen.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
$response = new GetRefundDataResponse();
$options = $request->participant->payment_options ?? [];
$iban = Iban::normalize((string) ($options[self::OPTION_PAYER_IBAN] ?? ''));
$owner = trim((string) ($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? ''));
if ($iban === '' || $owner === '' || !Iban::isValid($iban)) {
return $response;
}
$response->source = RefundAccountSource::Known;
$response->accountOwner = $owner;
$response->accountIban = $iban;
$paidOn = $request->participant->last_payment_date?->format('d.m.Y');
$response->sourceNote = $paidOn === null
? 'Zahlungseingang'
: 'Zahlungseingang vom ' . $paidOn;
return $response;
}
/** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */
public function isRelevantTransaction(BankTransaction $transaction): bool
{
return $transaction->isCredit();
}
/**
* Erkennt die Anmeldung zu einem Zahlungseingang.
*
* Die Regeln greifen in fester Reihenfolge und liefern nur dann etwas, wenn **genau eine**
* Anmeldung passt. Bei zwei Treffern -- zwei Geschwister mit gleichem Nachnamen, zwei
* Namensgleiche im selben Lager -- gibt es bewusst keinen Vorschlag: die falsche Zuordnung wäre
* in der Prüfansicht nicht zu erkennen und würde durchgewinkt.
*
* @param Collection<int, EventParticipant> $candidates
*/
public function matchTransaction(BankTransaction $transaction, Collection $candidates): ?TransactionMatch
{
$purpose = $this->normalize($transaction->purpose);
$payerName = $this->normalize($transaction->payerName);
$payerIban = Iban::normalize($transaction->payerIban);
// 1) Der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug -- der Normalfall.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool {
$reference = $this->normalize((string) $participant->payment_purpose);
return $reference !== '' && str_contains($purpose, $reference);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 2) Verwendungszweck abgetippt oder gekürzt, aber beide Namen sind noch drin.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool {
$firstname = $this->normalize((string) $participant->firstname);
$lastname = $this->normalize((string) $participant->lastname);
return $firstname !== '' && $lastname !== ''
&& str_contains($purpose, $firstname) && str_contains($purpose, $lastname);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 3) Folgezahlung: Von diesem Konto kam bereits ein Beitrag für genau eine Anmeldung.
if ($payerIban !== '') {
$match = $this->onlyOne($candidates, static function (EventParticipant $participant) use ($payerIban): bool {
$known = (string) (($participant->payment_options ?? [])[self::OPTION_PAYER_IBAN] ?? '');
return $known !== '' && Iban::normalize($known) === $payerIban;
});
if ($match !== null) {
return new TransactionMatch($match);
}
}
// 4) Nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest auf den Cent.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose, $transaction): bool {
$lastname = $this->normalize((string) $participant->lastname);
if ($lastname === '' || !str_contains($purpose, $lastname)) {
return false;
}
return round($this->amountLeft($participant)->getAmount(), 2) === round($transaction->amount->getAmount(), 2);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 5) Der Zweck sagt nichts, aber das Konto läuft auf den Namen der Anmeldung. Das trägt oft,
// liegt aber bei Eltern- und Gemeinschaftskonten auch daneben -- deshalb nur „unsicher".
if ($payerName !== '') {
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($payerName): bool {
$firstname = $this->normalize((string) $participant->firstname);
$lastname = $this->normalize((string) $participant->lastname);
return $lastname !== '' && $payerName === $firstname . $lastname;
});
if ($match !== null) {
return new TransactionMatch($match, TransactionMatch::CONFIDENCE_UNCERTAIN);
}
}
return null;
}
/**
* Hält fest, von welchem Konto der Beitrag kam.
*
* Das ist mehr als eine Notiz: Erstattet wird ausschließlich auf das Konto, von dem der Beitrag
* gezahlt wurde. Bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht
* weiß -- mit `refund_data` weiß sie es.
*
* Eine IBAN, die die Prüfziffer nicht besteht, wird nicht übernommen: Auf ein Konto mit
* Zahlendreher zu erstatten hieße, das Geld an eine fremde Person zu überweisen.
*/
public function recordTransaction(EventParticipant $participant, BankTransaction $transaction): void
{
$iban = Iban::normalize($transaction->payerIban);
$owner = trim($transaction->payerName);
if ($iban === '' || !Iban::isValid($iban)) {
return;
}
$options = $participant->payment_options ?? [];
// Die jüngste Zahlung gewinnt: Erstattet wird auf das Konto, von dem der Beitrag kam --
// zahlt jemand die zweite Rate von einem anderen Konto, ist das nun dieses.
$options[self::OPTION_PAYER_IBAN] = $iban;
// Einen bereits bekannten Kontoinhaber nicht durch einen leeren Namen ersetzen: Manche Banken
// lassen das Feld bei Folgezahlungen leer, und ohne Inhaber ist die IBAN für die Erstattung
// wertlos.
if ($owner !== '') {
$options[self::OPTION_PAYER_ACCOUNT_OWNER] = $owner;
}
$participant->payment_options = $options;
// Nur hochsetzen, nie zurück: Was wir einmal wissen, wissen wir.
if (($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? '') !== '') {
$participant->refund_data = true;
}
}
/**
* Genau ein Treffer oder nichts.
*
* @param Collection<int, EventParticipant> $candidates
* @param callable(EventParticipant): bool $matches
*/
private function onlyOne(Collection $candidates, callable $matches): ?EventParticipant
{
$hits = $candidates->filter($matches);
return $hits->count() === 1 ? $hits->first() : null;
}
/** Noch offener Beitrag. `clone`, weil Amount seine Rechenoperationen auf sich selbst ausführt. */
private function amountLeft(EventParticipant $participant): Amount
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
return $amountLeft;
}
/** Kleinschreibung, Umlaute aufgelöst, alles außer [a-z0-9] raus. */
private function normalize(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
'á' => 'a', 'à' => 'a', 'â' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e',
'í' => 'i', 'ì' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ú' => 'u', 'ù' => 'u',
'ç' => 'c', 'ñ' => 'n',
]);
return preg_replace('/[^a-z0-9]/', '', $value) ?? '';
}
public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{
$participant = $request->participant;
$config = $request->configuration;
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
$hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +351,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
if ($amountLeft->getAmount() <= 0) {
return null;
@@ -2,8 +2,11 @@
namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -57,6 +60,25 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
return $response;
}
/**
* Bar gezahlt heißt: Es gab nie ein Konto, von dem der Beitrag kam.
*
* Damit greift die sonst geltende Kontrolle „zurück nur auf das Ursprungskonto" nicht -- die Frage
* danach wäre für den Teili sinnlos und die Erklärung, es sei dasselbe Konto, schlicht unwahr. An
* ihre Stelle tritt die Erklärung, dass das angegebene Konto auf seinen Namen läuft.
*
* Zurückgezahlt wird trotzdem per Überweisung: Der Kontoauszug belegt die Zahlung, während eine
* Barauszahlung an einer Unterschrift hinge und die Barkasse berührte. Rechtlich spricht nichts
* dagegen -- eine Regel „bar rein, bar raus" gibt es nicht.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
$response = new GetRefundDataResponse();
$response->source = RefundAccountSource::None;
return $response;
}
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{
return sprintf(
@@ -0,0 +1,25 @@
<?php
namespace App\EventPaymentModules;
use App\ValueObjects\BankStatementRuleset;
/**
* Fähigkeit: Dieses Zahlungsmodul pflegt das Format des Kontoauszugs (CSV-Export der Bank).
*
* Bewusst getrennt von {@see ReadsBankStatements}: Das CSV-Format ist eine Eigenschaft der Bank, nicht
* der Zahlungsart. Eine Bank, ein Export, ein Ruleset -- deshalb implementiert es genau **ein** Modul
* (die Überweisung), und ein künftiges Lastschrift-Modul liest denselben Auszug, ohne dasselbe Format
* ein zweites Mal pflegen zu lassen. Sonst wäre nach dem nächsten Bankwechsel eine der beiden Stellen
* vergessen.
*
* Wie überall in dieser Schicht wird die Konfiguration **hereingereicht**, nicht selbst geholt: die
* Module bleiben frei von Datenbankzugriffen und damit ohne DB testbar.
*/
interface ProvidesStatementRuleset
{
/**
* @param array<string, mixed> $configuration Tenant-Konfiguration dieses Moduls
*/
public function statementRuleset(array $configuration): BankStatementRuleset;
}
@@ -0,0 +1,42 @@
<?php
namespace App\EventPaymentModules;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\Models\EventParticipant;
use App\ValueObjects\BankTransaction;
use Illuminate\Support\Collection;
/**
* Fähigkeit: Dieses Zahlungsmodul kann Umsätze eines Kontoauszugs verwerten.
*
* Hier stecken die drei Entscheidungen, die von der Zahlungsart abhängen -- und nur diese:
* was zählt, wem es gehört, was nachgetragen wird. Der Ablauf drumherum (Upload, Parsen, Kandidaten
* laden, Wasserzeichen, `amount_paid` fortschreiben, Mail) ist zahlartneutral und liegt in der Action.
*
* Die Überweisung erkennt Gutschriften am Verwendungszweck; eine SEPA-Lastschrift wird später
* Belastungen und Rücklastschriften über die Mandatsreferenz erkennen -- gleiche Datei, andere Regeln.
*/
interface ReadsBankStatements
{
/** Interessiert dieser Umsatz diese Zahlungsart? */
public function isRelevantTransaction(BankTransaction $transaction): bool;
/**
* Zu welcher Anmeldung gehört der Umsatz?
*
* `null`, wenn keine oder mehrere passen -- ein unsicherer Vorschlag wäre schlimmer als keiner,
* weil er in der Prüfansicht bloß durchgewinkt würde.
*
* @param Collection<int, EventParticipant> $candidates fertig geladen; hier wird nichts abgefragt
*/
public function matchTransaction(BankTransaction $transaction, Collection $candidates): ?TransactionMatch;
/**
* Trägt die zahlartspezifischen Spuren des Umsatzes an der Anmeldung ein.
*
* Setzt nur die Attribute, **speichert nicht** -- gespeichert wird einmal am Ende durch die Action,
* zusammen mit dem Betrag.
*/
public function recordTransaction(EventParticipant $participant, BankTransaction $transaction): void;
}
@@ -2,8 +2,10 @@
namespace App\Mail\ParticipantRefundMails;
use App\Enumerations\RefundAccountSource;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
@@ -45,6 +47,17 @@ class RefundReleasedMail extends Mailable
'reasonNote' => $this->refund->reason_note,
// Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
'link' => url('/rueckerstattung/' . $this->refund->token),
// Steht das Konto schon fest (aus dem Zahlungseingang), fragt die Mail nicht nach der
// Bankverbindung, sondern nur noch nach der Entscheidung auszahlen/spenden.
//
// Maskiert, aus demselben Grund wie auf der Seite: Diese Mail liegt in einem Postfach.
'knownAccountOwner' => $this->refund->account_owner,
'knownAccountIban' => $this->refund->account_iban === null
? null
: Iban::mask($this->refund->account_iban),
// Ohne Ursprungskonto (Barzahlung) wäre der Hinweis „nur auf das Konto, von dem gezahlt
// wurde" falsch -- dort ist stattdessen ein Konto auf den eigenen Namen gefragt.
'hasOriginAccount' => $this->participant->refundData()->source !== RefundAccountSource::None,
],
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @property string $name
* @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 boolean $mail_on_new
* @property boolean $allow_new
+45
View File
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $cost_unit_id
* @property string $name
* @property string $location
* @property string|null $street
* @property string|null $house_number
* @property string $postal_code
* @property string $email
* @property DateTime $start_date
@@ -45,6 +47,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property float $support_flat
* @property int $alcoholics_age
* @property boolean $archived
* @property boolean $short_registration
* @property boolean $swimming_permission_required
* @property string|null $invoice_key
*/
class Event extends InstancedModel
@@ -57,6 +61,8 @@ class Event extends InstancedModel
'name',
'identifier',
'location',
'street',
'house_number',
'postal_code',
'email',
'start_date',
@@ -91,6 +97,9 @@ class Event extends InstancedModel
'participation_options',
'addons',
'short_registration',
'swimming_permission_required',
'invoice_key',
];
@@ -121,8 +130,23 @@ class Event extends InstancedModel
'participation_options' => 'array',
'addons' => 'array',
'short_registration' => 'boolean',
'swimming_permission_required' => 'boolean',
];
/**
* Anschrift des Veranstaltungsorts in einer Zeile. Straße und Hausnummer sind optional -- fehlen sie,
* bleibt es bei der bisherigen Ausgabe "PLZ Ort".
*/
public function getFullAddress(): string
{
$city = trim($this->postal_code . ' ' . $this->location);
$street = trim($this->street . ' ' . $this->house_number);
return '' === $street ? $city : $street . ', ' . $city;
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant', 'slug');
@@ -205,4 +229,25 @@ class Event extends InstancedModel
return $this->hasMany(EventParticipant::class);
}
/**
* Slug der ersten konfigurierten Teilnahmegruppe. Die Kurzanmeldung fragt die Gruppe nicht ab und ordnet
* 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.
*/
/**
* Die erste hinterlegte Teilnahmegruppe -- die Kurzanmeldung meldet immer in dieser an.
*/
public function firstParticipationFee() : ?EventParticipationFee {
return collect([
$this->participationFee1,
$this->participationFee2,
$this->participationFee3,
$this->participationFee4,
])->first(fn ($participationFee) => $participationFee !== null);
}
public function firstParticipationTypeSlug() : ?string {
return $this->firstParticipationFee()?->type;
}
}
+23
View File
@@ -8,6 +8,8 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -74,6 +76,8 @@ class EventParticipant extends InstancedModel
'payment_purpose',
'payment_method',
'payment_options',
'refund_data',
'last_payment_date',
'efz_status',
'unregistered_at',
];
@@ -94,6 +98,8 @@ class EventParticipant extends InstancedModel
'amount' => AmountCast::class,
'amount_paid' => AmountCast::class,
'payment_options' => 'array',
'refund_data' => 'boolean',
'last_payment_date' => 'date',
'invoice_sequence' => 'integer',
'sibling_reduction' => 'boolean',
@@ -234,4 +240,21 @@ class EventParticipant extends InstancedModel
return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration(), $context));
}
/**
* Auf welches Konto wäre zu erstatten -- und woher kommt es?
*
* Einmal hier statt viermal verstreut: Die Freigabe, die Teilnehmerliste, die öffentliche
* Erstattungsseite und der Beleg stellen dieselbe Frage und müssen dieselbe Antwort bekommen.
* Fehlt die Zahlungsart, gilt die strengere Vorgabe des Moduls-Standards (Ursprungskonto).
*/
public function refundData(): GetRefundDataResponse
{
$module = $this->paymentModule();
if ($module === null) {
return new GetRefundDataResponse();
}
return $module->getRefundData(new GetRefundDataRequest($this, $this->paymentConfiguration()));
}
}
+18 -2
View File
@@ -58,13 +58,15 @@ class PaymentMethod extends CommonModel
}
/**
* Teilnehmer-Eingabe-Schema für einen Slug (payer-seitig).
* Teilnehmer-Eingabe-Schema für einen Slug (payer-seitig) -- das, was das Anmeldeformular
* abfragt. Programmatisch befüllte Felder (`system => true`, z.B. das vom Zahlungsimport
* nachgetragene Zahler-Konto) sind hier bewusst nicht dabei.
*
* @return array<int, array{name: string, label: string, type: string, required: bool}>
*/
public static function participantOptionsFor(string $slug): array
{
return EventPaymentModuleRegistry::forSlug($slug)?->getParticipantOptions() ?? [];
return EventPaymentModuleRegistry::forSlug($slug)?->participantInputOptions() ?? [];
}
/**
@@ -88,6 +90,20 @@ class PaymentMethod extends CommonModel
return EventPaymentModuleRegistry::forSlug($slug)?->sanitizeConfiguration($config) ?? [];
}
/**
* Entfernt die tenant-weiten Optionen eines Slugs aus einer Konfiguration.
*
* Aufgerufen beim Copy-on-Assign an eine Veranstaltung: Was den Mandanten als Ganzes betrifft
* (z.B. das Kontoauszug-Format), darf nicht pro Aktion einfrieren.
*
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
public static function stripTenantScopedOptions(string $slug, array $config): array
{
return EventPaymentModuleRegistry::forSlug($slug)?->stripTenantScopedOptions($config) ?? $config;
}
/**
* Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind.
* Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder.
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Support\BankStatementParseException;
use App\ValueObjects\Amount;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
/**
* Liest den CSV-Export der Bank in {@see BankTransaction}-Objekte.
*
* Zustandslos und zahlartneutral: hier wird nur gelesen, nicht bewertet. Ob ein Umsatz überhaupt
* interessiert, entscheidet das Zahlungsmodul über `isRelevantTransaction()` -- die Überweisung will
* Gutschriften, die SEPA-Lastschrift später genau andersherum Belastungen und Rücklastschriften.
*/
class BankStatementParseProvider
{
/**
* @return array<int, BankTransaction>
*
* @throws BankStatementParseException wenn eine Pflichtspalte im Export fehlt
*/
public function parse(string $contents, BankStatementRuleset $ruleset): array
{
$missing = $ruleset->missingRequiredColumns();
if ($missing !== []) {
throw new BankStatementParseException(
'Im Kontoauszug-Format fehlt die Zuordnung für: ' . implode(', ', $missing)
. '. Bitte die Spaltenzuordnung in den Zahlungsmethoden prüfen.'
);
}
$lines = $this->lines($this->toUtf8($contents, $ruleset->charset));
if ($lines === []) {
throw new BankStatementParseException('Die Datei enthält keine Daten.');
}
// Ohne Kopfzeile ließen sich die Spalten nur über ihre Position ansprechen -- dann bräche der
// Import stillschweigend, sobald die Bank eine Spalte einfügt. Lieber hier abbrechen.
if (!$ruleset->hasHeader) {
throw new BankStatementParseException('Der Export muss eine Kopfzeile mit den Spaltennamen enthalten.');
}
$indexes = $this->resolveColumnIndexes(array_shift($lines), $ruleset);
$transactions = [];
foreach ($lines as $offset => $line) {
$fields = $this->splitLine($line, $ruleset);
$date = $this->parseDate($this->field($fields, $indexes, 'payment_date'), $ruleset);
$amount = $this->parseAmount($this->field($fields, $indexes, 'amount'), $ruleset);
// Zeilen ohne lesbares Datum oder ohne Betrag sind keine Umsätze (Summenzeilen, Fußnoten,
// Leerzeilen mit Trennzeichen) -- die werden still übergangen statt den Import abzubrechen.
if ($date === null || $amount === null) {
continue;
}
$transactions[] = new BankTransaction(
paymentDate: $date,
amount: $amount,
purpose: $this->field($fields, $indexes, 'purpose'),
payerName: $this->field($fields, $indexes, 'payer_name'),
payerIban: $this->field($fields, $indexes, 'payer_iban'),
// +2: die Kopfzeile ist Zeile 1, $offset zählt ab 0 in der Restliste.
rowNumber: $offset + 2,
);
}
return $transactions;
}
/**
* Bringt den Inhalt nach UTF-8.
*
* Der Schutz gegen Doppel-Kodierung ist der wichtige Teil: liegt bereits gültiges UTF-8 mit
* Mehrbyte-Zeichen vor, wird nicht noch einmal konvertiert. Sonst würde aus „Müller" ein
* „Müller" -- und zwar unbemerkt, weil die Konvertierung technisch gelingt.
*/
private function toUtf8(string $contents, string $charset): string
{
$contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;
$isUtf8 = mb_check_encoding($contents, 'UTF-8');
$hasMultiByte = strlen($contents) !== mb_strlen($contents, 'UTF-8');
if ($isUtf8 && $hasMultiByte) {
return $contents;
}
if ($charset === 'auto') {
return $isUtf8 ? $contents : (string) mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
}
if ($charset === 'UTF-8') {
return $contents;
}
return (string) mb_convert_encoding($contents, 'UTF-8', $charset);
}
/** @return array<int, string> */
private function lines(string $contents): array
{
$lines = preg_split('/\r\n|\r|\n/', $contents) ?: [];
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
}
/**
* Fachliches Feld => Spaltenindex, aufgelöst über die Kopfzeile.
*
* @return array<string, int>
*
* @throws BankStatementParseException
*/
private function resolveColumnIndexes(string $headerLine, BankStatementRuleset $ruleset): array
{
$headers = array_map(
static fn (string $header): string => mb_strtolower(trim($header)),
$this->splitLine($headerLine, $ruleset),
);
$indexes = [];
$notFound = [];
foreach ($ruleset->columns as $field => $column) {
$index = array_search(mb_strtolower(trim($column)), $headers, true);
if ($index === false) {
if (in_array($field, BankStatementRuleset::REQUIRED_COLUMNS, true)) {
$notFound[] = $column;
}
continue;
}
$indexes[$field] = (int) $index;
}
if ($notFound !== []) {
throw new BankStatementParseException(
'Die Datei enthält keine Spalte "' . implode('", "', $notFound) . '". '
. 'Entweder wurde der falsche Export hochgeladen, oder das eingestellte '
. 'Kontoauszug-Format passt nicht zu dieser Bank.'
);
}
return $indexes;
}
/**
* @return array<int, string>
*/
private function splitLine(string $line, BankStatementRuleset $ruleset): array
{
// str_getcsv() wirft bei leerem Enclosure einen ValueError. Ohne Anführungszeichen -- so
// liefert es die GLS -- ist ein schlichtes explode() ohnehin das Richtige.
$fields = $ruleset->enclosure === ''
? explode($ruleset->delimiter, $line)
: str_getcsv($line, $ruleset->delimiter, $ruleset->enclosure, '\\');
return array_map(static fn ($field): string => trim((string) $field), $fields);
}
/**
* @param array<int, string> $fields
* @param array<string, int> $indexes
*/
private function field(array $fields, array $indexes, string $name): string
{
$index = $indexes[$name] ?? null;
return $index === null ? '' : ($fields[$index] ?? '');
}
private function parseDate(string $value, BankStatementRuleset $ruleset): ?CarbonImmutable
{
if ($value === '') {
return null;
}
$date = CarbonImmutable::createFromFormat('!' . $ruleset->dateFormat, $value);
return $date === false ? null : $date;
}
/** „1.234,56" bzw. „-56,00" -> Amount. */
private function parseAmount(string $value, BankStatementRuleset $ruleset): ?Amount
{
if ($value === '') {
return null;
}
$normalized = $value;
if ($ruleset->thousandsSeparator !== '') {
$normalized = str_replace($ruleset->thousandsSeparator, '', $normalized);
}
$normalized = str_replace($ruleset->decimalSeparator, '.', $normalized);
$normalized = str_replace(' ', '', $normalized);
if (!is_numeric($normalized)) {
return null;
}
return new Amount((float) $normalized, 'Euro');
}
}
@@ -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 {
return $this->belongsTo(Tenant::class, 'tenant', 'slug');
}
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Models\Event;
use App\Models\EventParticipant;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class EventParticipantRepository {
public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array {
@@ -39,6 +40,39 @@ class EventParticipantRepository {
return $participant;
}
/**
* Anmeldungen, gegen die ein Zahlungseingang zugeordnet werden kann.
*
* Abgemeldete sind ausdrücklich dabei: Wer den Beitrag überwiesen und sich danach abgemeldet hat,
* steht trotzdem im Kontoauszug. Die Zahlung muss erfasst werden -- erst dann gibt es überhaupt
* etwas zu erstatten. Ließe man sie hier weg, bliebe der Eingang unzuordenbar und das Geld läge
* unbemerkt auf dem Konto.
*
* `event.paymentMethods` wird mitgeladen, weil die Zuordnung je Anmeldung das Zahlungsmodul
* auflöst und das sonst pro Zeile eine Abfrage wäre.
*
* @return \Illuminate\Support\Collection<int, EventParticipant>
*/
public function getForPaymentMatching(Event $event) : Collection {
return $event->participants()
->with('event.paymentMethods')
->orderBy('lastname')
->orderBy('firstname')
->get();
}
/**
* Eine Anmeldung dieser Veranstaltung -- der Identifier kommt beim Zahlungsimport aus dem Browser
* und wird deshalb hier gegen die Aktion geprüft. Der Tenant-Filter kommt vom SiteScope.
*
* Abgemeldete eingeschlossen, aus demselben Grund wie in {@see getForPaymentMatching()}.
*/
public function findInEventByIdentifier(Event $event, string $identifier) : ?EventParticipant {
return EventParticipant::where('identifier', $identifier)
->where('event_id', $event->id)
->first();
}
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\AvailablePaymentMethod;
/**
* Zugriff auf die Zahlungsmethoden-Instanzen des Mandanten.
*
* Existiert, damit die Zahlungsmodule DB-frei bleiben können: Sie bekommen ihre Konfiguration als
* Array hereingereicht ({@see \App\EventPaymentModules\ProvidesStatementRuleset}) und holen sie sich
* nicht selbst -- sonst bräuchte jeder Modul-Unit-Test eine Datenbank.
*/
class PaymentMethodRepository
{
/**
* Konfiguration einer Zahlungsart beim aktuellen Mandanten. Der Tenant-Filter kommt vom SiteScope.
*
* @return array<string, mixed>
*/
public function tenantConfiguration(string $slug): array
{
return (array) (AvailablePaymentMethod::where('slug', $slug)->first()?->configuration ?? []);
}
}
+1
View File
@@ -49,6 +49,7 @@ class CostUnitResource {
$data = array_merge(
$this->costUnit->toArray(),
[
'billingDeadline' => $this->costUnit->billing_deadline,
'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(),
'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(),
'totalAmount' => new Amount($totalAmount, ' Euro')->toString(),
+36 -4
View File
@@ -9,6 +9,7 @@ use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -51,12 +52,15 @@ class EventParticipantResource extends JsonResource
'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
'swimmingPermission' => $this->resource->swimmingPermission()->first()->short,
'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name,
// Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
// lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
'extendedFirstAid' => $this->resource->firstAidPermission()->first()?->name ?? 'Unbekannt',
'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt',
'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null,
'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport],
'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()->name,
'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()?->name
?? $this->resource->participation_type,
'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(),
@@ -79,13 +83,14 @@ class EventParticipantResource extends JsonResource
// Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
// der letzte abgebrochen wurde.
'refund' => $this->refund($request),
'refundData' => $this->refundData(),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
'state' => config('postCode.map.' . $this->resource->postcode),
'localGroupState' => null !== $this->resource->localGroup()->first()?->postcode ? config('postCode.map.' . $this->resource->postcode) : '--',
'birthday' => $this->resource->birthday->format('d.m.Y'),
'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()->name,
'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()?->name ?? 'Unbekannt',
'paymentMethod' => $this->resource->payment_method !== null
? AvailablePaymentMethod::withoutGlobalScopes()->where('tenant', $this->resource->tenant)->where('slug', $this->resource->payment_method)->first()?->name ?? $this->resource->payment_method
: null,
@@ -94,14 +99,17 @@ class EventParticipantResource extends JsonResource
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red',
default => 'bg-yellow',
},
'efzStatusReadable' => match($this->resource->efz_status) {
EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft',
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich',
default => 'Nicht geprüft',
},
'eventName' => $this->resource->event()->first()->name,
'eventAddress' => $event->getFullAddress(),
'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'),
'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'),
'participationOptions' => $this->resource->selectedOptions()->get()->map(
@@ -124,4 +132,28 @@ class EventParticipantResource extends JsonResource
return $refund?->toResource()->toArray($request);
}
/**
* Das Konto, auf das erstattet würde -- sofern die Zahlungsart es kennt.
*
* Unmaskiert: Diese Resource speist die Teilnehmerliste der Aktionsleitung, die IBANs ohnehin
* sehen und eingeben darf. Auf der öffentlichen Erstattungsseite geht dieselbe Angabe maskiert
* hinaus, dort besorgt das der RefundPageController.
*
* @return array<string, mixed>
*/
private function refundData(): array
{
$refundData = $this->resource->refundData();
return [
// `available` heißt für das Frontend: Das Konto steht fest, es wird nicht mehr erfragt.
'available' => $refundData->hasAccount(),
'accountOwner' => $refundData->accountOwner,
'accountIban' => $refundData->accountIban === null
? null
: Iban::format($refundData->accountIban),
'source' => $refundData->sourceNote,
];
}
}
+11 -2
View File
@@ -30,7 +30,12 @@ class EventResource extends JsonResource{
'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup',
'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier,
'location' => $this->event->location,
'street' => $this->event->street,
'houseNumber' => $this->event->house_number,
'postalCode' => $this->event->postal_code,
// Fertig formatierte Anschrift für alle Anzeigen -- die Einzelfelder brauchen nur die
// Bearbeitungsformulare.
'fullAddress' => $this->event->getFullAddress(),
'email' => $this->event->email,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
@@ -76,6 +81,10 @@ class EventResource extends JsonResource{
$returnArray['nameShort'] = substr($returnArray['nameShort'], 8, 13) . '...';
}
// Steuert im Frontend, welcher Anmelde-Wizard gerendert wird, und ob die Badeerlaubnis abgefragt wird.
$returnArray['shortRegistration'] = (bool)$this->event->short_registration;
$returnArray['swimmingPermissionRequired'] = (bool)$this->event->swimming_permission_required;
$returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true;
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
$returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY;
@@ -230,8 +239,8 @@ class EventResource extends JsonResource{
}
public function getMultiplier() : float {
$earlyBirdEnd = $this->event->early_bird_end;
if ($earlyBirdEnd > now()) {
// Der Frühbucherpreis gilt einschließlich des ganzen Stichtags.
if (now() <= $this->event->early_bird_end->copy()->endOfDay()) {
return 1;
}
+3
View File
@@ -13,6 +13,7 @@ use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
use App\Repositories\PaymentMethodRepository;
use App\Repositories\UserRepository;
abstract class CommonController {
@@ -27,6 +28,7 @@ abstract class CommonController {
protected EventParticipantRepository $eventParticipants;
protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates;
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
@@ -40,6 +42,7 @@ abstract class CommonController {
$this->eventParticipants = new EventParticipantRepository();
$this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository();
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support;
use RuntimeException;
/**
* Die hochgeladene Datei passt nicht zum eingestellten Ruleset.
*
* Die Meldung ist für die Aktionsleitung gedacht und wird unverändert angezeigt -- sie muss also
* sagen, welche Spalte fehlt oder was sonst nicht stimmt, nicht bloß „Fehler beim Einlesen".
*/
class BankStatementParseException extends RuntimeException
{
}
+35
View File
@@ -57,6 +57,41 @@ final class Iban
return trim(chunk_split(self::normalize($iban), 4, ' '));
}
/**
* Nur Land, Prüfziffer und die letzten vier Stellen -- der Rest wird zu Punkten.
*
* Für Seiten, die über einen Token aus einer Mail erreichbar sind: Die eigene Bankverbindung
* erkennt man daran wieder, wer den Token aus einem fremden Postfach fischt, liest sie aber
* nicht mit.
*/
public static function mask(string $iban): string
{
$iban = self::normalize($iban);
// Zu kurz zum Maskieren wäre auch zu kurz zum Erkennen -- dann lieber alles verdecken.
$masked = strlen($iban) <= 8
? str_repeat('•', max(strlen($iban), 1))
: substr($iban, 0, 4) . str_repeat('•', strlen($iban) - 8) . substr($iban, -4);
return self::group($masked);
}
/**
* In Vierergruppen, zeichen- statt byteweise.
*
* `chunk_split()` zählt Bytes und würde das drei Byte lange „•" mitten durchschneiden -- heraus
* käme Zeichensalat.
*
* @param string $value
*/
private static function group(string $value): string
{
return implode(' ', array_map(
static fn (array $chunk): string => implode('', $chunk),
array_chunk(mb_str_split($value), 4),
));
}
/**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 … Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
+6 -9
View File
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail;
class CloseCostUnit implements CronTask {
public function handle(): void
{
$now = now();
$costUnitRepository = new CostUnitRepository();
$now = now();
$billingResetEnd = clone $now;
@@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask {
$billingEnd = $costUnit['billing_deadline'];
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(
CostUnit::where('id', $costUnit['id'])->first(),
Amount::fromString($costUnit['distance_allowance']),
@@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask {
))->execute();
}
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
$billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd);
if (false === $billingEndTime) {
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd);
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
}
if (false === $billingEndTime) {
@@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask {
continue;
}
$billingEndTime->setTime(0,0,0);
$billingEndTime->add(new \DateInterval('P1D'));
// Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden.
$billingEndTime->setTime(23, 59, 59);
if ($billingEndTime < $now) {
if ($now > $billingEndTime) {
new ChangeCostUnitStateCommand(
new ChangeCostUnitStateRequest(
CostUnit::where('id', $costUnit['id'])->first(),false, false
+2 -1
View File
@@ -12,7 +12,8 @@ class CloseEvent implements CronTask {
$eventRepository = new EventRepository();
/** @var Event $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->save();
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\ValueObjects;
/**
* Wie ist der CSV-Export der Bank aufgebaut? Trennzeichen, Anführungszeichen, Zeichensatz, Zahl- und
* Datumsformat sowie die Zuordnung fachliches Feld -> Spaltenüberschrift.
*
* Zahlartneutral und damit bewusst außerhalb von {@see \App\EventPaymentModules}: Überweisung und
* (künftig) SEPA-Lastschrift lesen denselben Kontoauszug, nur werten sie ihn verschieden aus.
*
* Ein Tenant überschreibt das Ruleset **ganz oder gar nicht** ({@see fromConfiguration()}). Ein
* feldweiser Merge wäre die schlechtere Zusage: wer das Trennzeichen umstellt, weil seine Bank ein
* anderes Format liefert, bekäme sonst weiterhin die Spaltennamen der GLS untergeschoben und suchte
* den Fehler an der falschen Stelle.
*/
final readonly class BankStatementRuleset
{
/** Fachliche Felder, ohne die sich eine Zahlung nicht verarbeiten lässt. */
public const array REQUIRED_COLUMNS = ['payment_date', 'purpose', 'amount'];
/** Auswählbare Zeichensätze; 'auto' probiert UTF-8 und fällt sonst auf Windows-1252 zurück. */
public const array CHARSETS = ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15'];
/**
* @param array<string, string> $columns fachliches Feld => Spaltenüberschrift
*/
public function __construct(
public string $delimiter,
public string $enclosure,
public string $charset,
public bool $hasHeader,
public string $dateFormat,
public string $decimalSeparator,
public string $thousandsSeparator,
public array $columns,
) {
}
/** Der app-weite Standard aus config/bankStatement.php. */
public static function default(): self
{
return self::fromArray((array) config('bankStatement.default_ruleset', []));
}
/**
* Ruleset aus einer Modul-Konfiguration. Leerer/fehlender Override -> App-Standard.
*
* @param array<string, mixed>|null $override
*/
public static function fromConfiguration(?array $override): self
{
if ($override === null || $override === []) {
return self::default();
}
return self::fromArray($override);
}
/**
* Einzelne fehlende Schlüssel werden aus dem Config-Standard ergänzt -- das ist kein Widerspruch
* zum „ganz oder gar nicht": greift nur, wenn überhaupt ein Override existiert, und verhindert,
* dass ein unvollständig gespeichertes Formular einen leeren Delimiter erzeugt.
*
* @param array<string, mixed> $values
*/
private static function fromArray(array $values): self
{
$fallback = (array) config('bankStatement.default_ruleset', []);
$columns = (array) ($values['columns'] ?? $fallback['columns'] ?? []);
$columns = array_filter(
array_map(static fn ($column): string => trim((string) $column), $columns),
static fn (string $column): bool => $column !== '',
);
$charset = (string) ($values['charset'] ?? $fallback['charset'] ?? 'auto');
return new self(
delimiter: self::firstNonEmpty($values['delimiter'] ?? null, $fallback['delimiter'] ?? null, ';'),
// Der Leerstring ist hier ein gültiger Wert (keine Anführungszeichen) und darf nicht
// durch einen Fallback ersetzt werden.
enclosure: (string) ($values['enclosure'] ?? $fallback['enclosure'] ?? ''),
charset: in_array($charset, self::CHARSETS, true) ? $charset : 'auto',
hasHeader: (bool) ($values['has_header'] ?? $fallback['has_header'] ?? true),
dateFormat: self::firstNonEmpty($values['date_format'] ?? null, $fallback['date_format'] ?? null, 'd.m.Y'),
decimalSeparator: self::firstNonEmpty($values['decimal_separator'] ?? null, $fallback['decimal_separator'] ?? null, ','),
thousandsSeparator: (string) ($values['thousands_separator'] ?? $fallback['thousands_separator'] ?? '.'),
columns: $columns,
);
}
private static function firstNonEmpty(mixed ...$candidates): string
{
foreach ($candidates as $candidate) {
if ($candidate !== null && (string) $candidate !== '') {
return (string) $candidate;
}
}
return '';
}
/** Spaltenüberschrift für ein fachliches Feld, oder null wenn nicht gemappt. */
public function column(string $field): ?string
{
return $this->columns[$field] ?? null;
}
/**
* Pflichtfelder, für die keine Spalte gemappt ist.
*
* @return array<int, string>
*/
public function missingRequiredColumns(): array
{
return array_values(array_filter(
self::REQUIRED_COLUMNS,
fn (string $field): bool => $this->column($field) === null,
));
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'delimiter' => $this->delimiter,
'enclosure' => $this->enclosure,
'charset' => $this->charset,
'has_header' => $this->hasHeader,
'date_format' => $this->dateFormat,
'decimal_separator' => $this->decimalSeparator,
'thousands_separator' => $this->thousandsSeparator,
'columns' => $this->columns,
];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\ValueObjects;
use Carbon\CarbonImmutable;
/**
* Ein Umsatz aus dem Kontoauszug -- das Ergebnis einer geparsten CSV-Zeile, noch ohne jede Zuordnung.
*
* Zahlartneutral: ob der Umsatz überhaupt interessiert (Gutschrift bei der Überweisung, Belastung und
* Rücklastschrift bei der SEPA-Lastschrift) entscheidet das jeweilige Zahlungsmodul, nicht der Parser.
*
* `amount` trägt das Vorzeichen der Bank: Gutschriften positiv, Belastungen negativ.
*/
final readonly class BankTransaction
{
public function __construct(
public CarbonImmutable $paymentDate,
public Amount $amount,
public string $purpose,
public string $payerName,
public string $payerIban,
/** Zeilennummer in der hochgeladenen Datei -- Schlüssel der Zeile in der Prüfansicht. */
public int $rowNumber,
) {
}
public function isCredit(): bool
{
return $this->amount->getAmount() > 0;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'rowNumber' => $this->rowNumber,
'paymentDate' => $this->paymentDate->format('Y-m-d'),
'paymentDateFormatted' => $this->paymentDate->format('d.m.Y'),
'amount' => $this->amount->getAmount(),
'amountFormatted' => $this->amount->toString(),
'purpose' => $this->purpose,
'payerName' => $this->payerName,
'payerIban' => $this->payerIban,
];
}
}
+254
View File
@@ -0,0 +1,254 @@
<script setup>
import {computed, ref, watch} from 'vue'
/**
* Formular für das CSV-Format eines Kontoauszugs (Options-Typ `bank-ruleset`).
*
* Zwei Zustände: „App-Standard" (v-model leer) und „eigenes Format" (v-model trägt ein JSON-Objekt).
* Das ist die Zusage aus dem Backend -- ein Override gilt ganz oder gar nicht, es wird nicht feldweise
* mit dem Standard gemischt. Deshalb schaltet hier ein Schalter um und nicht jedes Feld für sich.
*
* Der Wert geht als JSON-String durch das generische Options-Formular; das Zahlungsmodul dekodiert ihn.
*/
const props = defineProps({
// JSON-String oder Objekt; leer = App-Standard.
modelValue: {type: [String, Object], default: ''},
// Der app-weite Standard, als Vorbelegung und Vergleich.
defaults: {type: Object, default: () => ({})},
charsets: {type: Array, default: () => ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15']},
})
const emit = defineEmits(['update:modelValue'])
const FIELDS = [
{key: 'payment_date', label: 'Zahlungsdatum', required: true, hint: 'Üblich: der Buchungstag.'},
{key: 'purpose', label: 'Verwendungszweck', required: true},
{key: 'amount', label: 'Betrag', required: true},
{key: 'payer_name', label: 'Name des Zahlers', required: false},
{key: 'payer_iban', label: 'IBAN des Zahlers', required: false, hint: 'Ohne diese Spalte bleiben die Erstattungsdaten leer.'},
]
function parse(value) {
if (!value) return null
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
function blank() {
return {
delimiter: ';',
enclosure: '',
charset: 'auto',
has_header: true,
date_format: 'd.m.Y',
decimal_separator: ',',
thousands_separator: '.',
columns: {},
}
}
// Eine tiefe Kopie, damit das Bearbeiten nicht die angezeigten Standardwerte überschreibt.
function fromDefaults() {
return JSON.parse(JSON.stringify({...blank(), ...props.defaults}))
}
const custom = ref(parse(props.modelValue) !== null)
const form = ref(parse(props.modelValue) ?? fromDefaults())
watch(() => props.modelValue, (value) => {
const parsed = parse(value)
custom.value = parsed !== null
form.value = parsed ?? fromDefaults()
})
const shown = computed(() => (custom.value ? form.value : fromDefaults()))
const missingRequired = computed(
() => FIELDS.filter(field => field.required && !(shown.value.columns ?? {})[field.key]).map(field => field.label),
)
function useAppDefault() {
custom.value = false
emit('update:modelValue', '')
}
function useOwnFormat() {
custom.value = true
form.value = fromDefaults()
push()
}
function push() {
emit('update:modelValue', JSON.stringify(form.value))
}
function setColumn(key, value) {
form.value.columns = {...(form.value.columns ?? {})}
if (value) {
form.value.columns[key] = value
} else {
delete form.value.columns[key]
}
push()
}
</script>
<template>
<div class="ruleset">
<label class="mode">
<input type="radio" :checked="!custom" @change="useAppDefault"/>
App-Standard verwenden
</label>
<label class="mode">
<input type="radio" :checked="custom" @change="useOwnFormat"/>
Eigenes Format für unsere Bank
</label>
<p v-if="!custom" class="note">
Es gilt der app-weite Standard. Bearbeitet ihr das Format, gilt ausschließlich eure
Einstellung — der Standard wirkt dann nicht mehr nach.
</p>
<table class="ruleset-table">
<tr>
<td>Trennzeichen</td>
<td>
<input type="text" class="form-input short" :disabled="!custom"
:value="shown.delimiter" @input="form.delimiter = $event.target.value; push()"/>
</td>
</tr>
<tr>
<td>Anführungszeichen</td>
<td>
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keine)"
:value="shown.enclosure" @input="form.enclosure = $event.target.value; push()"/>
</td>
</tr>
<tr>
<td>Zeichensatz</td>
<td>
<select class="form-input" :disabled="!custom"
:value="shown.charset" @change="form.charset = $event.target.value; push()">
<option v-for="charset in charsets" :key="charset" :value="charset">
{{ charset === 'auto' ? 'Automatisch erkennen' : charset }}
</option>
</select>
</td>
</tr>
<tr>
<td>Datumsformat</td>
<td>
<input type="text" class="form-input short" :disabled="!custom"
:value="shown.date_format" @input="form.date_format = $event.target.value; push()"/>
<span class="option-hint">PHP-Schreibweise, z.&nbsp;B. d.m.Y für 09.09.2026</span>
</td>
</tr>
<tr>
<td>Dezimaltrenner</td>
<td>
<input type="text" class="form-input short" :disabled="!custom"
:value="shown.decimal_separator" @input="form.decimal_separator = $event.target.value; push()"/>
</td>
</tr>
<tr>
<td>Tausendertrenner</td>
<td>
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keiner)"
:value="shown.thousands_separator" @input="form.thousands_separator = $event.target.value; push()"/>
</td>
</tr>
</table>
<h4>Spalten der Datei</h4>
<p class="note">
Die Überschrift der jeweiligen Spalte, genau so wie sie in der ersten Zeile des Exports steht.
</p>
<table class="ruleset-table">
<tr v-for="field in FIELDS" :key="field.key">
<td>{{ field.label }}<span v-if="field.required" class="required-marker">*</span></td>
<td>
<input type="text" class="form-input" :disabled="!custom"
:value="(shown.columns ?? {})[field.key] ?? ''"
@input="setColumn(field.key, $event.target.value)"/>
<span v-if="field.hint" class="option-hint">{{ field.hint }}</span>
</td>
</tr>
</table>
<p v-if="custom && missingRequired.length" class="warning">
Ohne {{ missingRequired.join(', ') }} lässt sich kein Kontoauszug einlesen.
</p>
</div>
</template>
<style scoped>
.ruleset {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
background-color: #fafafa;
}
.mode {
display: block;
margin-bottom: 4px;
cursor: pointer;
}
.mode input {
margin-right: 6px;
}
.note {
margin: 6px 0 10px 0;
color: #6b7280;
font-size: 0.85rem;
}
.ruleset-table {
width: 100%;
border-collapse: collapse;
}
.ruleset-table td {
padding: 4px 6px 4px 0;
vertical-align: top;
}
.ruleset-table td:first-child {
width: 190px;
color: #374151;
}
.form-input.short {
width: 90px;
}
h4 {
margin: 16px 0 0 0;
color: #374151;
}
.option-hint {
display: block;
color: #6b7280;
font-size: 0.8rem;
margin-top: 2px;
}
.required-marker {
color: #ef4444;
margin-left: 2px;
}
.warning {
margin: 10px 0 0 0;
color: #991b1b;
font-size: 0.85rem;
}
</style>
+305 -17
View File
@@ -1,57 +1,229 @@
<script setup>
import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import Icon from './Icon.vue'
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
// keine SVG-Icons rendern). Die Options-Liste wird per <Teleport> an <body> gehängt und fixed
// positioniert, damit sie nicht von einem Eltern-Container mit overflow (z. B. .tab-content in
// TabbedPage) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
// TabbedPage oder der FullScreenModal) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
//
// Optional: Gruppen (option.group), eine zweite Textzeile je Eintrag (option.description) und ein
// Tipp-Filter (filterable). Alles drei ist opt-in -- Bestandsaufrufe bleiben unverändert.
const props = defineProps({
// Ausgewählter Wert (value einer Option).
modelValue: {type: String, default: ''},
// [{ value, label, icon? }]
/*
* [{ value, label, description?, icon?, group?, muted? }]
*
* group -- Überschrift, unter der der Eintrag einsortiert wird. Einträge ohne group stehen
* ungruppiert oben (z. B. ein "— keine Auswahl —"-Eintrag).
* muted -- gedämpft dargestellt, für Einträge mit Sonderbedeutung.
*/
options: {type: Array, default: () => []},
placeholder: {type: String, default: 'Auswählen…'},
// Trigger wird zum Texteingabefeld: Tippen filtert die Liste. Übernommen wird trotzdem immer
// eine Option, nie der getippte Text.
filterable: {type: Boolean, default: false},
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const root = ref(null)
const listRef = ref(null)
const inputRef = ref(null)
const pos = ref({top: 0, left: 0, width: 0})
// Was im Eingabefeld steht, und getrennt davon, wonach gefiltert wird: Beim Öffnen zeigt das Feld
// das Label der Auswahl, gefiltert wird aber erst, wenn wirklich getippt wurde.
//
// Der Feldtext MUSS über einen eigenen Ref laufen und darf nicht direkt an `selected.label` hängen:
// Vue patcht `value` bei jedem Re-Render, auch wenn der gebundene Wert gleich geblieben ist -- die
// Eingabe würde sonst bei jedem Tastendruck wieder auf das alte Label zurückspringen.
const inputText = ref('')
const query = ref('')
const highlighted = ref(-1)
const selected = computed(() => props.options.find(o => o.value === props.modelValue) ?? null)
/**
* Kleingeschrieben und ohne Sonderzeichen, in zwei Formen: einmal mit ausgeschriebenem Umlaut
* (ü → ue), einmal mit bloßem Grundbuchstaben (ü → u).
*
* Beide, weil beide Schreibweisen vorkommen: Wer „Müller" sucht, tippt mal `mueller`, mal `muller`,
* mal mit Umlaut. Ein Treffer in einer der beiden Formen genügt.
*/
function normalizeForms(value) {
const base = String(value ?? '').toLowerCase()
return [
clean(base.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')),
clean(base.replace(/ß/g, 'ss')),
]
}
/** Akzente auf den Grundbuchstaben zurückführen, alles außer [a-z0-9] verwerfen. */
function clean(value) {
return String(value ?? '')
// Restliche Akzente (é, ñ, …) auf den Grundbuchstaben zurückführen.
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, '')
}
const matches = computed(() => {
const [needleLong, needleShort] = normalizeForms(query.value)
if (!props.filterable || needleLong === '') {
return props.options
}
// Gesucht wird nur im Label, nicht in der Beschreibung: Die trägt den Zustand ("abgemeldet am
// …", "120,00 € offen") und liest sich bei allen ähnlich -- ein "me" träfe sonst jedes
// "abgemeldet" und die Liste stünde voller Zufallstreffer.
return props.options.filter(option => {
const [long, short] = normalizeForms(option.label)
return long.includes(needleLong) || short.includes(needleShort)
})
})
/**
* Die gefilterten Optionen nach Gruppen, in Reihenfolge des ersten Auftretens -- die Sortierung
* liegt damit beim Aufrufer. Einträge ohne `group` bilden den ersten, überschriftenlosen Block.
*/
const groups = computed(() => {
const result = []
const byLabel = new Map()
for (const option of matches.value) {
const label = option.group ?? null
if (!byLabel.has(label)) {
const group = {label, options: []}
byLabel.set(label, group)
result.push(group)
}
byLabel.get(label).options.push(option)
}
return result
})
/** Flache Reihenfolge über alle Gruppen -- die Grundlage für die Pfeiltasten. */
const flatOptions = computed(() => groups.value.flatMap(group => group.options))
function updatePosition() {
if (!root.value) return
const rect = root.value.getBoundingClientRect()
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
}
async function toggle() {
open.value = !open.value
if (open.value) {
async function openList() {
if (open.value) return
open.value = true
query.value = ''
inputText.value = selected.value?.label ?? ''
highlighted.value = flatOptions.value.findIndex(o => o.value === props.modelValue)
await nextTick()
updatePosition()
}
/**
* Schließt die Liste und stellt den Feldtext auf die bestehende Auswahl zurück.
*
* Damit wird getippter Freitext nie zum Wert: Wer die Liste ohne Auswahl verlässt (wegklicken,
* Escape), sieht wieder das, was tatsächlich ausgewählt ist.
*/
function closeList() {
open.value = false
query.value = ''
highlighted.value = -1
inputText.value = selected.value?.label ?? ''
}
async function toggle() {
if (open.value) {
closeList()
return
}
await openList()
}
/**
* Klick ins Eingabefeld öffnet und markiert den Text, damit Tippen ihn ersetzt.
*
* Nur beim Öffnen: Ein zweiter Klick ins bereits offene Feld soll den Cursor setzen dürfen, statt
* die Markierung wiederherzustellen.
*/
async function onInputFocus() {
if (open.value) return
await openList()
inputRef.value?.select()
}
function onInput(event) {
inputText.value = event.target.value
query.value = event.target.value
// Nach dem Tippen steht die Markierung auf dem ersten Treffer -- Enter wählt damit das
// Naheliegende, ohne dass jemand erst die Pfeiltaste suchen muss.
highlighted.value = flatOptions.value.length > 0 ? 0 : -1
}
function select(option) {
emit('update:modelValue', option.value)
open.value = false
query.value = ''
highlighted.value = -1
// Nicht über closeList(): `selected` liest noch den alten Wert, der Emit oben wirkt erst mit
// dem nächsten Render zurück.
inputText.value = option.label
inputRef.value?.blur()
}
function onClickOutside(event) {
const inRoot = root.value && root.value.contains(event.target)
const inList = listRef.value && listRef.value.contains(event.target)
if (!inRoot && !inList) {
open.value = false
closeList()
}
}
function moveHighlight(step) {
const count = flatOptions.value.length
if (count === 0) return
const next = highlighted.value + step
highlighted.value = next < 0 ? count - 1 : next >= count ? 0 : next
nextTick(() => {
listRef.value
?.querySelector('.rich-select__option--highlighted')
?.scrollIntoView({block: 'nearest'})
})
}
function onKeydown(event) {
if (event.key === 'Escape' || event.key === 'Esc') {
open.value = false
if (!open.value) return
// Ohne das schließt derselbe Escape auch die umgebende FullScreenModal (die lauscht am
// window, wir am document -- und document kommt zuerst). Im Zahlungsimport wären damit
// sämtliche Zuordnungen weg.
event.stopPropagation()
closeList()
return
}
if (!open.value) return
if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
} else if (event.key === 'Enter') {
const option = flatOptions.value[highlighted.value]
if (option) {
event.preventDefault()
select(option)
}
}
}
@@ -59,6 +231,14 @@ function onReposition() {
if (open.value) updatePosition()
}
// Setzt der Aufrufer den Wert von außen (oder werden die Optionen nachgeladen), zieht der Feldtext
// nach -- solange gerade niemand tippt.
watch([() => props.modelValue, () => props.options], () => {
if (!open.value) {
inputText.value = selected.value?.label ?? ''
}
}, {immediate: true})
onMounted(() => {
document.addEventListener('click', onClickOutside)
document.addEventListener('keydown', onKeydown)
@@ -76,7 +256,26 @@ onBeforeUnmount(() => {
<template>
<div ref="root" class="rich-select">
<div v-if="filterable" class="rich-select__trigger rich-select__trigger--input">
<Icon v-if="selected?.icon && !open" :key="selected.value" :name="selected.icon"/>
<input
ref="inputRef"
type="text"
class="rich-select__input"
role="combobox"
autocomplete="off"
:aria-expanded="open"
:value="inputText"
:placeholder="placeholder"
@focus="onInputFocus"
@click="onInputFocus"
@input="onInput"
/>
<span class="rich-select__caret" aria-hidden="true">▾</span>
</div>
<button
v-else
type="button"
class="rich-select__trigger"
:aria-expanded="open"
@@ -100,18 +299,38 @@ onBeforeUnmount(() => {
role="listbox"
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
>
<li v-if="flatOptions.length === 0" class="rich-select__empty">
Kein Treffer für „{{ query }}"
</li>
<template v-for="group in groups" :key="group.label ?? '_'">
<li v-if="group.label" class="rich-select__group" role="presentation">
{{ group.label }} <span class="rich-select__group-count">({{ group.options.length }})</span>
</li>
<li
v-for="option in options"
v-for="option in group.options"
:key="option.value"
class="rich-select__option"
:class="{ 'rich-select__option--active': option.value === modelValue }"
:class="{
'rich-select__option--active': option.value === modelValue,
'rich-select__option--highlighted': option === flatOptions[highlighted],
'rich-select__option--muted': option.muted,
}"
role="option"
:aria-selected="option.value === modelValue"
@click="select(option)"
@mouseenter="highlighted = flatOptions.indexOf(option)"
>
<Icon v-if="option.icon" :name="option.icon"/>
<span>{{ option.label }}</span>
<Icon v-if="option.icon" :name="option.icon" class="rich-select__option-icon"/>
<span class="rich-select__option-text">
<span class="rich-select__option-label">{{ option.label }}</span>
<span v-if="option.description" class="rich-select__option-description">
{{ option.description }}
</span>
</span>
</li>
</template>
</ul>
</Teleport>
</div>
@@ -129,12 +348,12 @@ onBeforeUnmount(() => {
justify-content: space-between;
width: 100%;
gap: 10px;
padding: 6px 10px;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.95rem;
font-size: 1rem;
box-sizing: border-box;
}
@@ -142,6 +361,22 @@ onBeforeUnmount(() => {
border-color: #2563eb;
}
.rich-select__trigger--input:focus-within {
border-color: #2563eb;
}
.rich-select__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
padding: 0;
font-size: 1rem;
font-family: inherit;
color: inherit;
background: transparent;
}
.rich-select__value {
display: flex;
align-items: center;
@@ -168,26 +403,79 @@ onBeforeUnmount(() => {
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 260px;
max-height: 320px;
overflow: auto;
box-sizing: border-box;
}
.rich-select__group {
padding: 10px 10px 4px 10px;
color: #6b7280;
font-size: 0.72rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rich-select__group-count {
font-weight: normal;
letter-spacing: 0;
}
.rich-select__empty {
padding: 14px 10px;
color: #9ca3af;
text-align: center;
}
.rich-select__option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
}
.rich-select__option:hover {
.rich-select__option-icon {
color: #6b7280;
flex-shrink: 0;
}
.rich-select__option-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.rich-select__option-label {
font-size: 1rem;
line-height: 1.3;
}
.rich-select__option-description {
color: #6b7280;
font-size: 0.8rem;
line-height: 1.3;
margin-top: 1px;
}
.rich-select__option--muted .rich-select__option-label {
color: #6b7280;
}
/* Tastatur und Maus zeigen dieselbe Markierung -- sonst „wandert“ beim Wechsel der Eingabeart ein
zweiter Hinweis durch die Liste. */
.rich-select__option:hover,
.rich-select__option--highlighted {
background: #eff6ff;
}
.rich-select__option--active {
background: #dbeafe;
}
.rich-select__option--active .rich-select__option-label {
font-weight: 600;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* App-weiter Standard für das Einlesen von Kontoauszügen (CSV-Export der Bank).
*
* Voreingestellt ist das Format der GLS Gemeinschaftsbank. Ein Tenant kann das Ruleset in der
* Konfiguration des Zahlungsmoduls „Überweisung" überschreiben (Option `statement_ruleset`); ist dort
* etwas hinterlegt, gilt ausschließlich dieser Override -- es wird nicht feldweise gemischt.
*
* `columns` bildet die fachlichen Felder auf die Spaltenüberschriften der Bank ab. Gelesen wird über
* die Kopfzeile, nicht über Spaltenpositionen: eine Bank, die ihre Spalten umsortiert, bleibt lesbar.
*/
return [
'default_ruleset' => [
// Trennzeichen der Spalten.
'delimiter' => ';',
// Anführungszeichen um die Werte. Leer = die Bank setzt keine (so die GLS).
'enclosure' => '',
// Zeichensatz der Datei: 'auto' | 'UTF-8' | 'Windows-1252' | 'ISO-8859-15'.
'charset' => 'Windows-1252',
// Erste Zeile enthält die Spaltenüberschriften.
'has_header' => true,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
/*
* Pflicht sind payment_date, purpose und amount -- ohne sie lässt sich eine Zahlung weder
* datieren noch zuordnen noch buchen. payer_name und payer_iban sind optional; fehlen sie,
* funktioniert der Import, nur die Erstattungsdaten bleiben leer.
*
* payment_date liest bewusst den Buchungstag: das ist der Tag, an dem das Geld ankam. Keine
* Spalte des Exports sagt, wann der Auftrag abgeschickt wurde -- der Buchungstag kommt dem am
* nächsten. Eine Bank ohne diese Spalte lässt sich hier auf ihre Valuta-Spalte umbiegen.
*/
'columns' => [
'payment_date' => 'Buchungstag',
'purpose' => 'Verwendungszweck',
'amount' => 'Betrag',
'payer_name' => 'Name Zahlungsbeteiligter',
'payer_iban' => 'IBAN Zahlungsbeteiligter',
],
],
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Zwei Spuren, die der Zahlungsimport aus dem Kontoauszug hinterlässt.
*
* `refund_data` -- ob wir wissen, von welchem Konto der Beitrag kam. Erstattet wird ausschließlich auf
* dieses Konto; bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht weiß.
* Die IBAN und der Kontoinhaber selbst liegen in `payment_options` (die Überweisung weiß, wie ihre
* Felder heißen), hier steht nur das Signal „vorhanden".
*
* `last_payment_date` -- der Buchungstag der zuletzt eingebuchten Zahlung. Er ist das Wasserzeichen
* gegen Doppelbuchung: Wird derselbe Auszug ein zweites Mal hochgeladen, fallen alle Umsätze heraus,
* die älter sind als die letzte erfasste Zahlung dieser Person.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->boolean('refund_data')->default(false)->after('payment_options');
$table->date('last_payment_date')->nullable()->after('refund_data');
});
}
public function down(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->dropColumn(['refund_data', 'last_payment_date']);
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Die Konto-Erklärung für Zahlungsarten ohne Ursprungskonto (Barzahlung, Zahlung vor Ort).
*
* `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT` lautet „dasselbe Konto, von dem der Teilnahmebeitrag gezahlt
* wurde" -- wer bar gezahlt hat, kann das nicht bestätigen, es gab kein solches Konto. Der Satz stünde
* anschließend trotzdem auf dem Eigenbeleg und schriebe der Person eine Zusicherung zu, die sie nicht
* abgeben konnte.
*
* An seine Stelle tritt hier die Kontrolle, die in diesem Fall tatsächlich greift: dass das angegebene
* Konto der Person selbst gehört. Damit lässt sich eine Erstattung weiterhin nicht auf ein fremdes Konto
* umleiten.
*
* Wie bei den übrigen Erklärungen in `page_texts` und nicht in der Dokumentvorlage, damit Seite und
* Beleg denselben Wortlaut zeigen. Geschrieben wird nur, was fehlt -- eine angepasste Fassung bleibt
* unangetastet.
*/
return new class extends Migration {
private const string NAME = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
private const string CONTENT = 'Ich bestätige, dass das angegebene Konto auf meinen Namen läuft '
. 'oder ich über dieses Konto verfügungsberechtigt bin.';
public function up(): void
{
if (DB::table('page_texts')->where('name', self::NAME)->first() !== null) {
return;
}
DB::table('page_texts')->insert([
'name' => self::NAME,
'content' => self::CONTENT,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('page_texts')->where('name', self::NAME)->delete();
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
// Verkürzter Anmeldeprozess: fragt nur die nötigsten Daten ab, fehlende Pflichtangaben werden
// beim Speichern mit Platzhaltern gefüllt. Bestandsveranstaltungen bleiben beim langen Prozess.
$table->boolean('short_registration')->default(false);
// Ob die Badeerlaubnis überhaupt abgefragt wird -- wirkt in beiden Anmeldeprozessen. Default true,
// damit bestehende Veranstaltungen sich unverändert verhalten.
$table->boolean('swimming_permission_required')->default(true);
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['short_registration', 'swimming_permission_required']);
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Der Veranstaltungsort war bisher nur PLZ und Ort. Straße und Hausnummer kommen dazu, bleiben aber
* optional: Bestandsveranstaltungen haben sie nicht, und bei der Anlage sind sie oft noch unbekannt.
*/
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->string('street')->nullable()->after('location');
$table->string('house_number')->nullable()->after('street');
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['street', 'house_number']);
});
}
};
@@ -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
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more