11 Commits
Author SHA1 Message Date
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
th.guenther 91dca31c11 Prepared new version 2026-09-07 14:18:19 +02:00
103 changed files with 7569 additions and 323 deletions
@@ -5,6 +5,7 @@ namespace App\Domains\Admin\Controllers;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Resources\AvailablePaymentMethodResource; use App\Resources\AvailablePaymentMethodResource;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\ValueObjects\BankStatementRuleset;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -16,6 +17,10 @@ class TenantPaymentMethodsGetController extends CommonController
'paymentMethods' => AvailablePaymentMethod::all() 'paymentMethods' => AvailablePaymentMethod::all()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)), ->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods', '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> <script setup>
import {computed, ref} from 'vue'; import {computed, ref} from 'vue';
import BankRulesetEditor from "../../../../Views/Components/BankRulesetEditor.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import TextEditor from "../../../../Views/Components/TextEditor.vue"; import TextEditor from "../../../../Views/Components/TextEditor.vue";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.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]" <RichSelectBox v-if="option.type === 'icon'" v-model="form.configuration[option.name]"
:options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/> :options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/> <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"/> <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> <span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td> </td>
@@ -33,7 +33,7 @@ function navigateTo(url) {
</tr> </tr>
<tr> <tr>
<td> <td>
{{participation.event.postal_code}} {{participation.event.location}}<br /> {{participation.eventAddress}}<br />
</td> </td>
<td> <td>
<a class="link" :href="`/api/v1/event/participant/${participation.identifier}/ical`">In Kalender importieren</a> <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 { public function execute() : CertificateOfConductionCheckResponse {
$response = new 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() $apiResponse = Http::acceptJson()
->asJson() ->asJson()
@@ -38,6 +38,8 @@ class CreateEventCommand {
'name' => $this->request->name, 'name' => $this->request->name,
'identifier' => Str::random(10), 'identifier' => Str::random(10),
'location' => $this->request->location, 'location' => $this->request->location,
'street' => $this->normalizeOptional($this->request->street),
'house_number' => $this->normalizeOptional($this->request->houseNumber),
'postal_code' => $this->request->postalCode, 'postal_code' => $this->request->postalCode,
'email' => $this->request->email, 'email' => $this->request->email,
'start_date' => $this->request->begin, 'start_date' => $this->request->begin,
@@ -107,6 +109,17 @@ class CreateEventCommand {
return $response; 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 * 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 * Veranstaltung, dann ohne Trenner Jahr, Monat des Beginns und die laufende Nummer der
@@ -19,8 +19,10 @@ class CreateEventRequest {
public string $accountOwner; public string $accountOwner;
public string $accountIban; public string $accountIban;
public bool $payPerDay; 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->name = $name;
$this->location = $location; $this->location = $location;
$this->postalCode = $postalCode; $this->postalCode = $postalCode;
@@ -34,5 +36,7 @@ class CreateEventRequest {
$this->accountOwner = $accountOwner; $this->accountOwner = $accountOwner;
$this->accountIban = $accountIban; $this->accountIban = $accountIban;
$this->payPerDay = $payPerDay; $this->payPerDay = $payPerDay;
$this->street = $street;
$this->houseNumber = $houseNumber;
} }
} }
@@ -18,7 +18,7 @@ class GenerateIcalCommand
$dtEnd = $event->end_date->copy()->addDay()->format('Ymd'); $dtEnd = $event->end_date->copy()->addDay()->format('Ymd');
$now = now()->format('Ymd\THis\Z'); $now = now()->format('Ymd\THis\Z');
$summary = $this->escapeIcal($event->name); $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()); $description = $this->escapeIcal('Teilnahme als: ' . $participant->getOfficialName());
$icalContent = implode("\r\n", [ $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. // Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
if ($override !== null) { if ($override !== null) {
$row = $existing[$slug]; $row = $existing[$slug];
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override); $row->configuration = $this->eventConfiguration($slug, $override);
$row->save(); $row->save();
} }
continue; continue;
@@ -75,8 +75,27 @@ class SetPaymentMethodsCommand
EventPaymentMethods::create([ EventPaymentMethods::create([
'event_id' => $event->id, 'event_id' => $event->id,
'slug' => $slug, '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\EatingHabit;
use App\Enumerations\EfzStatus; use App\Enumerations\EfzStatus;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\EventPaymentModuleRegistry; use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -59,7 +60,7 @@ class SignUpCommand {
'participation_type' => $this->request->participationType, 'participation_type' => $this->request->participationType,
'fee_type' => $this->request->feeType, 'fee_type' => $this->request->feeType,
'sibling_reduction' => $this->request->siblingReduction, 'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup->slug, 'local_group' => $this->request->localGroup?->slug,
'birthday' => $this->request->birthday, 'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1, 'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2, 'address_2' => $this->request->address_2,
@@ -75,9 +76,7 @@ class SignUpCommand {
'medications' => $this->request->medications, 'medications' => $this->request->medications,
'tetanus_vaccination' => $this->request->tetanus_vaccination, 'tetanus_vaccination' => $this->request->tetanus_vaccination,
'eating_habit' => $eatingHabit, 'eating_habit' => $eatingHabit,
'swimming_permission' => ($participantAge->isfullAged() || $this->request->swimming_permission === '-1') 'swimming_permission' => $this->resolveSwimmingPermission($participantAge),
? 'SWIMMING_PERMISSION_ALLOWED'
: $this->request->swimming_permission,
'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1') 'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1')
? 'FIRST_AID_PERMISSION_ALLOWED' ? 'FIRST_AID_PERMISSION_ALLOWED'
: $this->request->first_aid_permission, : $this->request->first_aid_permission,
@@ -109,6 +108,26 @@ class SignUpCommand {
return $response; 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 * Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der
* Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben * Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben
@@ -15,7 +15,8 @@ class SignUpRequest {
public string $lastname, public string $lastname,
public ?string $nickname, public ?string $nickname,
public string $participationType, public string $participationType,
public Tenant $localGroup, /** Stamm des Teilnehmers; in der Kurzanmeldung nicht erhoben und deshalb optional. */
public ?Tenant $localGroup,
public DateTime $birthday, public DateTime $birthday,
public string $address_1, public string $address_1,
public ?string $address_2, public ?string $address_2,
@@ -13,6 +13,8 @@ class UpdateEventCommand {
$this->request->event->name = $this->request->eventName; $this->request->event->name = $this->request->eventName;
$this->request->event->location = $this->request->eventLocation; $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->postal_code = $this->request->postalCode;
$this->request->event->email = $this->request->email; $this->request->event->email = $this->request->email;
$this->request->event->early_bird_end = $this->request->earlyBirdEnd; $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->support_flat = $this->request->flatSupport;
$this->request->event->send_weekly_report = $this->request->sendWeeklyReports; $this->request->event->send_weekly_report = $this->request->sendWeeklyReports;
$this->request->event->registration_allowed = $this->request->registrationAllowed; $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->save();
$this->request->event->resetAllowedEatingHabits(); $this->request->event->resetAllowedEatingHabits();
@@ -40,4 +44,15 @@ class UpdateEventCommand {
return $response; 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 Event $event;
public string $eventName; public string $eventName;
public string $eventLocation; public string $eventLocation;
public ?string $street;
public ?string $houseNumber;
public string $postalCode; public string $postalCode;
public string $email; public string $email;
public DateTime $earlyBirdEnd; public DateTime $earlyBirdEnd;
@@ -17,16 +19,20 @@ class UpdateEventRequest {
public int $alcoholicsAge; public int $alcoholicsAge;
public bool $sendWeeklyReports; public bool $sendWeeklyReports;
public bool $registrationAllowed; public bool $registrationAllowed;
public bool $shortRegistration;
public bool $swimmingPermissionRequired;
public Amount $flatSupport; public Amount $flatSupport;
public Amount $supportPerPerson; public Amount $supportPerPerson;
public array $contributingLocalGroups; public array $contributingLocalGroups;
public array $eatingHabits; 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->event = $event;
$this->eventName = $eventName; $this->eventName = $eventName;
$this->eventLocation = $eventLocation; $this->eventLocation = $eventLocation;
$this->street = $street;
$this->houseNumber = $houseNumber;
$this->postalCode = $postalCode; $this->postalCode = $postalCode;
$this->email = $email; $this->email = $email;
$this->earlyBirdEnd = $earlyBirdEnd; $this->earlyBirdEnd = $earlyBirdEnd;
@@ -34,6 +40,8 @@ class UpdateEventRequest {
$this->alcoholicsAge = $alcoholicsAge; $this->alcoholicsAge = $alcoholicsAge;
$this->sendWeeklyReports = $sendWeeklyReports; $this->sendWeeklyReports = $sendWeeklyReports;
$this->registrationAllowed = $registrationAllowed; $this->registrationAllowed = $registrationAllowed;
$this->shortRegistration = $shortRegistration;
$this->swimmingPermissionRequired = $swimmingPermissionRequired;
$this->flatSupport = $flatSupport; $this->flatSupport = $flatSupport;
$this->supportPerPerson = $supportPerPerson; $this->supportPerPerson = $supportPerPerson;
$this->contributingLocalGroups = $contributingLocalGroups; $this->contributingLocalGroups = $contributingLocalGroups;
@@ -31,7 +31,8 @@ class UpdateParticipantCommand {
$p->address_2 = $this->request->address_2; $p->address_2 = $this->request->address_2;
$p->postcode = $this->request->postcode; $p->postcode = $this->request->postcode;
$p->city = $this->request->city; $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->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$p->email_1 = $this->request->email_1; $p->email_1 = $this->request->email_1;
$p->phone_1 = $this->request->phone_1; $p->phone_1 = $this->request->phone_1;
@@ -14,7 +14,8 @@ class UpdateParticipantRequest {
public ?string $address_2, public ?string $address_2,
public string $postcode, public string $postcode,
public string $city, 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 $birthday,
public string $email_1, public string $email_1,
public string $phone_1, public string $phone_1,
@@ -17,6 +17,7 @@ class ArchivedEventsController extends CommonController
'name' => $event->name, 'name' => $event->name,
'location' => $event->location, 'location' => $event->location,
'postalCode' => $event->postal_code, 'postalCode' => $event->postal_code,
'fullAddress' => $event->getFullAddress(),
'eventBegin' => $event->start_date->format('d.m.Y'), 'eventBegin' => $event->start_date->format('d.m.Y'),
'eventEnd' => $event->end_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, $participationFeeType,
$request->input('eventAccount'), $request->input('eventAccount'),
$request->input('eventIban'), $request->input('eventIban'),
$payPerDay $payPerDay,
$request->input('eventStreet'),
$request->input('eventHouseNumber')
); );
$wasSuccessful = false; $wasSuccessful = false;
@@ -60,7 +60,11 @@ class DetailsController extends CommonController {
$flatSupport, $flatSupport,
$supportPerPerson, $supportPerPerson,
$contributinLocalGroups, $contributinLocalGroups,
$eatingHabits $eatingHabits,
(bool)$request->input('shortRegistration', false),
(bool)$request->input('swimmingPermissionRequired', true),
$request->input('street'),
$request->input('houseNumber'),
); );
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest); $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 <?php
use App\Domains\Event\Controllers\BankStatementBookController;
use App\Domains\Event\Controllers\BankStatementParseController;
use App\Domains\Event\Controllers\CreateController; use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController; use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\EventArchiveController; 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\ParticipantSignOffController;
use App\Domains\Event\Controllers\ParticipantUpdateController; use App\Domains\Event\Controllers\ParticipantUpdateController;
use App\Domains\Event\Controllers\PaymentReminderController; 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\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -25,6 +29,9 @@ Route::prefix('api/v1')
Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']); Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']);
Route::post('{eventId}/signup', [SignupController::class, 'signUp']); 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::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']); Route::post('/create', [CreateController::class, 'doCreate']);
@@ -48,6 +55,9 @@ Route::prefix('api/v1')
Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']); Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']);
Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']); Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']);
Route::post('/event-addons', [DetailsController::class, 'updateEventAddons']); 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> <div>
<h2 style="margin: 0 0 4px 0; font-size: 1.1rem; font-weight: 600;">{{ event.name }}</h2> <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;"> <span style="color: #6b7280; font-size: 0.875rem;">
{{ event.postalCode }} {{ event.location }} {{ event.fullAddress }}
&nbsp;·&nbsp; &nbsp;·&nbsp;
{{ event.eventBegin }} {{ event.eventEnd }} {{ event.eventBegin }} {{ event.eventEnd }}
</span> </span>
+14
View File
@@ -23,6 +23,8 @@
eventName: '', eventName: '',
eventPostalCode: '', eventPostalCode: '',
eventLocation: '', eventLocation: '',
eventStreet: '',
eventHouseNumber: '',
eventEmail: props.emailAddress ? props.emailAddress : '', eventEmail: props.emailAddress ? props.emailAddress : '',
eventBegin: '', eventBegin: '',
eventEnd: '', eventEnd: '',
@@ -145,6 +147,8 @@
eventName: formData.eventName, eventName: formData.eventName,
eventPostalCode: formData.eventPostalCode, eventPostalCode: formData.eventPostalCode,
eventLocation: formData.eventLocation, eventLocation: formData.eventLocation,
eventStreet: formData.eventStreet,
eventHouseNumber: formData.eventHouseNumber,
eventEmail: formData.eventEmail, eventEmail: formData.eventEmail,
eventBegin: formData.eventBegin, eventBegin: formData.eventBegin,
eventEnd: formData.eventEnd, eventEnd: formData.eventEnd,
@@ -195,6 +199,16 @@
<ErrorText :message="errors.eventLocation" /></td> <ErrorText :message="errors.eventLocation" /></td>
</tr> </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;"> <tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Postleitzahl des Veranstaltungsorts</th> <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" /> <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 class="available-event-header">
<div> <div>
<h2 class="available-event-title">{{ event.name }}</h2> <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> </div>
<span <span
v-if="event.registrationAllowed" v-if="event.registrationAllowed"
@@ -47,7 +47,7 @@ const props = defineProps({
</tr> </tr>
<tr> <tr>
<th>Veranstaltungsort</th> <th>Veranstaltungsort</th>
<td>{{ event.postalCode }} {{ event.location }}</td> <td>{{ event.fullAddress }}</td>
</tr> </tr>
<tr> <tr>
<th>Frühbuchen bis</th> <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, contributingLocalGroups: contributingLocalGroups.value,
eventName: props.event.name, eventName: props.event.name,
eventLocation: props.event.location, eventLocation: props.event.location,
street: props.event.street ?? '',
houseNumber: props.event.houseNumber ?? '',
postalCode: props.event.postalCode, postalCode: props.event.postalCode,
email: props.event.email, email: props.event.email,
earlyBirdEnd: props.event.earlyBirdEnd.internal, earlyBirdEnd: props.event.earlyBirdEnd.internal,
@@ -35,6 +37,8 @@ const emit = defineEmits(['close'])
eatingHabits: eatingHabits.value, eatingHabits: eatingHabits.value,
sendWeeklyReports: props.event.sendWeeklyReports, sendWeeklyReports: props.event.sendWeeklyReports,
registrationAllowed: props.event.registrationAllowed, registrationAllowed: props.event.registrationAllowed,
shortRegistration: props.event.shortRegistration,
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
flatSupport: props.event.flatSupportEdit, flatSupport: props.event.flatSupportEdit,
supportPerson: props.event.supportPersonIndex, supportPerson: props.event.supportPersonIndex,
}) })
@@ -57,6 +61,8 @@ const emit = defineEmits(['close'])
body: { body: {
eventName: formData.eventName, eventName: formData.eventName,
eventLocation: formData.eventLocation, eventLocation: formData.eventLocation,
street: formData.street,
houseNumber: formData.houseNumber,
postalCode: formData.postalCode, postalCode: formData.postalCode,
email: formData.email, email: formData.email,
earlyBirdEnd: formData.earlyBirdEnd, earlyBirdEnd: formData.earlyBirdEnd,
@@ -64,6 +70,8 @@ const emit = defineEmits(['close'])
alcoholicsAge: formData.alcoholicsAge, alcoholicsAge: formData.alcoholicsAge,
sendWeeklyReports: formData.sendWeeklyReports, sendWeeklyReports: formData.sendWeeklyReports,
registrationAllowed: formData.registrationAllowed, registrationAllowed: formData.registrationAllowed,
shortRegistration: formData.shortRegistration,
swimmingPermissionRequired: formData.swimmingPermissionRequired,
flatSupport: formData.flatSupport, flatSupport: formData.flatSupport,
supportPerson: formData.supportPerson, supportPerson: formData.supportPerson,
contributingLocalGroups: contributingLocalGroups.value, contributingLocalGroups: contributingLocalGroups.value,
@@ -105,6 +113,20 @@ const emit = defineEmits(['close'])
</td> </td>
</tr> </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> <tr>
<th>Postleitzahl des Veranstaltungsorts</th> <th>Postleitzahl des Veranstaltungsorts</th>
<td> <td>
@@ -172,6 +194,27 @@ const emit = defineEmits(['close'])
<label for="registrationAllowed">Veranstaltung ist für Anmeldungen geöffnet</label> <label for="registrationAllowed">Veranstaltung ist für Anmeldungen geöffnet</label>
</td> </td>
</tr> </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> </table>
@@ -6,6 +6,7 @@ import EventAddons from "./EventAddons.vue";
import ParticipationSummary from "./ParticipationSummary.vue"; import ParticipationSummary from "./ParticipationSummary.vue";
import CommonSettings from "./CommonSettings.vue"; import CommonSettings from "./CommonSettings.vue";
import EventManagement from "./EventManagement.vue"; import EventManagement from "./EventManagement.vue";
import BankStatementImport from "./BankStatementImport.vue";
import Modal from "../../../../Views/Components/Modal.vue"; import Modal from "../../../../Views/Components/Modal.vue";
import MailCompose from "./MailCompose.vue"; import MailCompose from "./MailCompose.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
@@ -21,6 +22,12 @@ const props = defineProps({
const displayData = ref('main'); const displayData = ref('main');
const showEventData = ref(false); 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() { async function showMain() {
@@ -117,9 +124,18 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" /> <input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/> </a><br/>
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
</div> </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" /> <ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event" <ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
@close="showMain"/> @close="showMain"/>
@@ -158,6 +174,7 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" /> <input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/> </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" @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" class="deny-button" value="Letzte Mahnung senden" style="display: none" /><br/>
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/> <input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
@@ -233,6 +233,7 @@ function saveParticipant() {
<td> <td>
<span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span> <span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span>
<select v-else v-model="form.localgroup"> <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> <option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option>
</select> </select>
</td> </td>
@@ -86,6 +86,16 @@ const retainedAmount = computed(() => {
const hasRetention = computed(() => retainedAmount.value > 0); 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( const retainedAmountReadable = computed(
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro' () => 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() !== ''; 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. // 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. // 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()) { if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.'; refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
} }
@@ -456,9 +467,14 @@ async function execRefund() {
amount: refundForm.amount, amount: refundForm.amount,
reason: refundForm.reason, reason: refundForm.reason,
reasonNote: refundForm.reasonNote, reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. Beim
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', // Sofort-Einreichen mit bekanntem Konto geht dieses mit; eingegeben wurde nichts.
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', accountOwner: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountOwner ?? refundForm.accountOwner)
: '',
accountIban: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountIban ?? refundForm.accountIban)
: '',
// Spende: kein Konto, trotzdem sofort eingereicht. // Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation', donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen. // 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: 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. 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"> <div class="refund-field">
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" /> <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 Teilnehmer*in trägt die Bankverbindung selbst ein
</template>
</label> </label>
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" /> <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 Bankverbindung liegt mir vor
</template>
</label> </label>
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" /> <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. eingereicht; der Teili erhält den Beleg per E-Mail.
</p> </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"> <div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label> <label for="refund_account_owner">Kontoinhaber*in</label>
<input <input
@@ -882,6 +926,31 @@ function mailToGroup(groupKey) {
color: #4b5563; 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 { .refund-field {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -26,6 +26,18 @@ const paymentMethodConfigurations = reactive({})
const showOptionsModal = ref(false) const showOptionsModal = ref(false)
const optionsMethod = ref(null) 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) { function openOptions(method) {
optionsMethod.value = method optionsMethod.value = method
showOptionsModal.value = true showOptionsModal.value = true
@@ -47,7 +59,7 @@ onMounted(async () => {
for (const method of availablePaymentMethods.value) { for (const method of availablePaymentMethods.value) {
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {} const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
const config = {} const config = {}
for (const option of method.optionsSchema ?? []) { for (const option of eventOptions(method)) {
config[option.name] = source[option.name] ?? '' config[option.name] = source[option.name] ?? ''
} }
paymentMethodConfigurations[method.slug] = config paymentMethodConfigurations[method.slug] = config
@@ -358,7 +370,7 @@ onMounted(async () => {
paymentMethod.name paymentMethod.name
}}</label> }}</label>
<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" class="link"
style="padding-left: 15px; font-size: 10pt;" style="padding-left: 15px; font-size: 10pt;"
@click="openOptions(paymentMethod)" @click="openOptions(paymentMethod)"
@@ -381,7 +393,7 @@ onMounted(async () => {
width="700px" width="700px"
@close="showOptionsModal = false" @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> <label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
<RichSelectBox v-if="option.type === 'icon'" <RichSelectBox v-if="option.type === 'icon'"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]" 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> </div>
</template> </template>
<style> <!-- Unscoped und mit der Kurzanmeldung geteilt (ShortSignupForm.vue bindet dieselbe Datei ein). -->
/* ─── Progress (Step-Pills) ─── */ <style src="./signupForm.css"></style>
.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>
@@ -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 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' errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
hasError = true hasError = true
} }
@@ -86,7 +86,7 @@ const next = () => {
<ErrorText :message="errors.email_2" /> <ErrorText :message="errors.email_2" />
</td> </td>
</tr> </tr>
<tr> <tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td> <td>Badeerlaubnis:</td>
<td> <td>
<select v-model="formData.badeerlaubnis"> <select v-model="formData.badeerlaubnis">
+9 -3
View File
@@ -3,6 +3,7 @@
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue"; import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue"; import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import SignupForm from './Partials/SignUpForm/SignupForm.vue' import SignupForm from './Partials/SignUpForm/SignupForm.vue'
import ShortSignupForm from './Partials/ShortSignUpForm/ShortSignupForm.vue'
import FullScreenModal from "../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../Views/Components/FullScreenModal.vue";
import AvailableEvents from "./Partials/AvailableEvents.vue"; import AvailableEvents from "./Partials/AvailableEvents.vue";
import {ref} from "vue"; import {ref} from "vue";
@@ -37,7 +38,7 @@ function close() {
</span> </span>
</div> </div>
<p class="signup-event-location"> <p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }} 📍 {{ props.event.fullAddress }}
</p> </p>
</div> </div>
<div v-else class="signup-header signup-header--closed"> <div v-else class="signup-header signup-header--closed">
@@ -48,7 +49,7 @@ function close() {
</span> </span>
</div> </div>
<p class="signup-event-location"> <p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }} 📍 {{ props.event.fullAddress }}
</p> </p>
</div> </div>
@@ -98,8 +99,13 @@ function close() {
<hr class="signup-divider" /> <hr class="signup-divider" />
<div class="signup-body"> <div class="signup-body">
<ShortSignupForm
v-if="props.event.registrationAllowed && props.event.shortRegistration"
:event="props.event"
:participantData="props.participantData ?? {}"
/>
<SignupForm <SignupForm
v-if="props.event.registrationAllowed" v-else-if="props.event.registrationAllowed"
:event="props.event" :event="props.event"
:participantData="props.participantData ?? {}" :participantData="props.participantData ?? {}"
:localGroups="props.localGroups ?? []" :localGroups="props.localGroups ?? []"
@@ -61,8 +61,16 @@ class AcceptRefundCommand
return $response; return $response;
} }
$owner = trim($this->request->accountOwner); // Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
$iban = Iban::normalize($this->request->accountIban); // 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 // 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 // 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.'; . '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 === '') { if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.'; $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.'; $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
} }
} }
}
if ($response->errorTypes !== []) { if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.'; $response->message = 'Bitte prüfe deine Angaben.';
@@ -8,8 +8,15 @@ class AcceptRefundRequest
{ {
public function __construct( public function __construct(
public readonly ?ParticipantRefund $refund, 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. * 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; namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Enumerations\RefundAccountSource;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Models\Event; use App\Models\Event;
use App\Models\EventParticipant; use App\Models\EventParticipant;
@@ -30,6 +31,12 @@ class CreateRefundDocumentCommand
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */ /** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'; 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. */ /** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION'; 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 private function declarationText(): string
{ {
if ($this->request->donation) { 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 // 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. // 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( return $this->pageText(
self::DECLARATION_TEXT, self::DECLARATION_TEXT,
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig ' 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.' . 'zurückerstattet bekomme.'
) . '<br /><br />' . $this->pageText( ) . '<br /><br />' . $this->pageText(
self::ACCOUNT_DECLARATION_TEXT, $accountSource->accountDeclarationText(),
'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag ' $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.' . 'gezahlt wurde.'
); );
} }
@@ -6,6 +6,7 @@ use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest; use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason; use App\Enumerations\RetentionReason;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\Mail\ParticipantRefundMails\RefundReleasedMail; use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
@@ -29,6 +30,8 @@ class ReleaseRefundCommand
private ParticipantRefundRepository $refunds; private ParticipantRefundRepository $refunds;
private ?GetRefundDataResponse $knownRefundData = null;
public function __construct(private readonly ReleaseRefundRequest $request) public function __construct(private readonly ReleaseRefundRequest $request)
{ {
$this->participant = $request->participant; $this->participant = $request->participant;
@@ -63,6 +66,10 @@ class ReleaseRefundCommand
'retained_amount' => $this->request->retainedAmount(), 'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(), 'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(), '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_by' => currentUser()?->id,
'released_at' => now(), 'released_at' => now(),
]); ]);
@@ -89,6 +96,31 @@ class ReleaseRefundCommand
return $response; 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. * Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
* *
@@ -3,16 +3,20 @@
namespace App\Domains\ParticipantRefund\Controllers; namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand; use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Enumerations\RefundAccountSource;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider; use App\Providers\InertiaProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\Support\Iban;
use Inertia\Response; use Inertia\Response;
/** /**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt. * 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. * 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 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, reasonNote: String,
acceptedAt: String, acceptedAt: String,
donation: Boolean, 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. // Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state) const state = ref(props.state)
@@ -70,6 +97,32 @@ const accountComplete = computed(
const isDonation = computed(() => decision.value === 'donation') 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. * 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.' errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) { 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.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.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
}
errors.accountDeclaration = form.accountDeclarationAccepted errors.accountDeclaration = form.accountDeclarationAccepted
? '' ? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.' : 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
@@ -111,8 +169,16 @@ async function submit() {
saving.value = true saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine. // 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 const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted} ? {donation: true, declarationAccepted: form.declarationAccepted}
: hasKnownAccount.value
? {
donation: false,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
: { : {
donation: false, donation: false,
accountOwner: form.accountOwner, accountOwner: form.accountOwner,
@@ -262,6 +328,44 @@ async function submit() {
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen. sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
--> -->
<template v-else-if="decision === 'payout'"> <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 <h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3> gezahlt wurde?</h3>
@@ -294,7 +398,14 @@ async function submit() {
</button> </button>
</template> </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"> <div class="field">
<label for="account-owner">Kontoinhaber*in</label> <label for="account-owner">Kontoinhaber*in</label>
<input <input
@@ -312,13 +423,17 @@ async function submit() {
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" /> <IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" /> <ErrorText :message="errors.accountIban" />
</div> </div>
</template>
<!-- <!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat. 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"> <div class="declaration">
<input <input
id="refund-declaration" id="refund-declaration"
@@ -339,7 +454,7 @@ async function submit() {
type="checkbox" type="checkbox"
/> />
<TextResource <TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT" :text-name="accountDeclarationText"
belongs-to="refund-account-declaration" belongs-to="refund-account-declaration"
/> />
</div> </div>
@@ -357,7 +472,6 @@ async function submit() {
</button> </button>
</template> </template>
</template> </template>
</template>
</form> </form>
</template> </template>
</template> </template>
@@ -429,6 +543,31 @@ h3 {
font-weight: bold; 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 { .field {
margin-bottom: 16px; 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\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest; use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse; 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\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -69,6 +72,37 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getOptions(), $config); 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 * @param array<string, mixed> $input
* @return array<string, mixed> * @return array<string, mixed>
@@ -84,6 +118,24 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getParticipantOptions(), $input); 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 * @param array<int, array{name: string, required?: bool}> $schema
* @return array<int, string> * @return array<int, string>
@@ -143,6 +195,19 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return new RegistrationSummaryResponse(); 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 public function doPayment(DoPaymentRequest $request): DoPaymentResponse
{ {
// TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen). // 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: - `EventPaymentModule`**Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`, `slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
`createInvoice()`. `createInvoice()`, `getRefundData()`.
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer - `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies. (`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart): - `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges). `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 - `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding. hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode`**Fähigkeits-Interface** (siehe unten). - `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements`**Fähigkeits-Interfaces** (siehe unten).
## Kernregeln ## Kernregeln
@@ -32,6 +33,15 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben. 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 - 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). (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`), - `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`. 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 - **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. 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 - `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul. 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) ## 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, - 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.** `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 ## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen - `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -118,6 +179,11 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
## Tests ## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`, `tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`, `tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5): `tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`docker exec mareike-mareike-app-1 php artisan test`. `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\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest; use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse; use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -68,4 +70,16 @@ interface EventPaymentModule
* (In dieser Iteration nur als Stub vorhanden.) * (In dieser Iteration nur als Stub vorhanden.)
*/ */
public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse; 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; namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule; use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\ProvidesGiroCode; use App\EventPaymentModules\ProvidesGiroCode;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider; 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. * Ü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 public static function slug(): string
{ {
return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION; return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
@@ -40,18 +61,263 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false], ['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' => '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' => '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 public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{ {
$participant = $request->participant; $participant = $request->participant;
$config = $request->configuration; $config = $request->configuration;
$amountLeft = clone $participant->amount; $amountLeft = $this->amountLeft($participant);
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$hasPaymentInformation = $amountLeft->getAmount() > 0; $hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +351,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string public function giroCode(EventParticipant $participant, array $configuration): ?string
{ {
$amountLeft = clone $participant->amount; $amountLeft = $this->amountLeft($participant);
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
if ($amountLeft->getAmount() <= 0) { if ($amountLeft->getAmount() <= 0) {
return null; return null;
@@ -2,8 +2,11 @@
namespace App\EventPaymentModules\Modules; namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule; use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -57,6 +60,25 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
return $response; 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 protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{ {
return sprintf( 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; namespace App\Mail\ParticipantRefundMails;
use App\Enumerations\RefundAccountSource;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
@@ -45,6 +47,17 @@ class RefundReleasedMail extends Mailable
'reasonNote' => $this->refund->reason_note, 'reasonNote' => $this->refund->reason_note,
// Absolute URL: der Link muss aus jedem Postfach heraus funktionieren. // Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
'link' => url('/rueckerstattung/' . $this->refund->token), '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,
], ],
); );
} }
+45
View File
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $cost_unit_id * @property int $cost_unit_id
* @property string $name * @property string $name
* @property string $location * @property string $location
* @property string|null $street
* @property string|null $house_number
* @property string $postal_code * @property string $postal_code
* @property string $email * @property string $email
* @property DateTime $start_date * @property DateTime $start_date
@@ -45,6 +47,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property float $support_flat * @property float $support_flat
* @property int $alcoholics_age * @property int $alcoholics_age
* @property boolean $archived * @property boolean $archived
* @property boolean $short_registration
* @property boolean $swimming_permission_required
* @property string|null $invoice_key * @property string|null $invoice_key
*/ */
class Event extends InstancedModel class Event extends InstancedModel
@@ -57,6 +61,8 @@ class Event extends InstancedModel
'name', 'name',
'identifier', 'identifier',
'location', 'location',
'street',
'house_number',
'postal_code', 'postal_code',
'email', 'email',
'start_date', 'start_date',
@@ -91,6 +97,9 @@ class Event extends InstancedModel
'participation_options', 'participation_options',
'addons', 'addons',
'short_registration',
'swimming_permission_required',
'invoice_key', 'invoice_key',
]; ];
@@ -121,8 +130,23 @@ class Event extends InstancedModel
'participation_options' => 'array', 'participation_options' => 'array',
'addons' => '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 public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class, 'tenant', 'slug'); return $this->belongsTo(Tenant::class, 'tenant', 'slug');
@@ -205,4 +229,25 @@ class Event extends InstancedModel
return $this->hasMany(EventParticipant::class); 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\FirstAidPermission;
use App\Enumerations\ParticipationType; use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission; use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -74,6 +76,8 @@ class EventParticipant extends InstancedModel
'payment_purpose', 'payment_purpose',
'payment_method', 'payment_method',
'payment_options', 'payment_options',
'refund_data',
'last_payment_date',
'efz_status', 'efz_status',
'unregistered_at', 'unregistered_at',
]; ];
@@ -94,6 +98,8 @@ class EventParticipant extends InstancedModel
'amount' => AmountCast::class, 'amount' => AmountCast::class,
'amount_paid' => AmountCast::class, 'amount_paid' => AmountCast::class,
'payment_options' => 'array', 'payment_options' => 'array',
'refund_data' => 'boolean',
'last_payment_date' => 'date',
'invoice_sequence' => 'integer', 'invoice_sequence' => 'integer',
'sibling_reduction' => 'boolean', 'sibling_reduction' => 'boolean',
@@ -234,4 +240,21 @@ class EventParticipant extends InstancedModel
return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration(), $context)); 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}> * @return array<int, array{name: string, label: string, type: string, required: bool}>
*/ */
public static function participantOptionsFor(string $slug): array 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) ?? []; 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. * 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. * 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 { public function tenant() : BelongsTo {
return $this->belongsTo(Tenant::class, 'tenant', 'slug'); return $this->belongsTo(Tenant::class, 'tenant', 'slug');
} }
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Models\Event; use App\Models\Event;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class EventParticipantRepository { class EventParticipantRepository {
public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array { public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array {
@@ -39,6 +40,39 @@ class EventParticipantRepository {
return $participant; 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 { public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request); $allParticipants = $this->getForList($event, $request);
$participants = []; $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 ?? []);
}
}
+36 -4
View File
@@ -9,6 +9,7 @@ use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Repositories\ParticipantRefundRepository; use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -51,12 +52,15 @@ class EventParticipantResource extends JsonResource
'fullname' => $this->resource->getFullName(), 'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(), 'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV', 'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
'swimmingPermission' => $this->resource->swimmingPermission()->first()->short, // Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name, // 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', 'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt',
'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null, 'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null,
'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport], '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 'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION && $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(), && $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 laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
// der letzte abgebrochen wurde. // der letzte abgebrochen wurde.
'refund' => $this->refund($request), 'refund' => $this->refund($request),
'refundData' => $this->refundData(),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age, 'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000', 'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000', 'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
'state' => config('postCode.map.' . $this->resource->postcode), 'state' => config('postCode.map.' . $this->resource->postcode),
'localGroupState' => null !== $this->resource->localGroup()->first()?->postcode ? 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'), '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 '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 ? AvailablePaymentMethod::withoutGlobalScopes()->where('tenant', $this->resource->tenant)->where('slug', $this->resource->payment_method)->first()?->name ?? $this->resource->payment_method
: null, : null,
@@ -94,14 +99,17 @@ class EventParticipantResource extends JsonResource
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green', EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow', EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red', EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red',
default => 'bg-yellow',
}, },
'efzStatusReadable' => match($this->resource->efz_status) { 'efzStatusReadable' => match($this->resource->efz_status) {
EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig', EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht', EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft', EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft',
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich', EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich',
default => 'Nicht geprüft',
}, },
'eventName' => $this->resource->event()->first()->name, 'eventName' => $this->resource->event()->first()->name,
'eventAddress' => $event->getFullAddress(),
'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'), 'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'),
'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'), 'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'),
'participationOptions' => $this->resource->selectedOptions()->get()->map( 'participationOptions' => $this->resource->selectedOptions()->get()->map(
@@ -124,4 +132,28 @@ class EventParticipantResource extends JsonResource
return $refund?->toResource()->toArray($request); 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,
];
}
} }
+9
View File
@@ -30,7 +30,12 @@ class EventResource extends JsonResource{
'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup', 'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup',
'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier, 'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier,
'location' => $this->event->location, 'location' => $this->event->location,
'street' => $this->event->street,
'houseNumber' => $this->event->house_number,
'postalCode' => $this->event->postal_code, '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, 'email' => $this->event->email,
'accountOwner' => $this->event->account_owner, 'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban, 'accountIban' => $this->event->account_iban,
@@ -76,6 +81,10 @@ class EventResource extends JsonResource{
$returnArray['nameShort'] = substr($returnArray['nameShort'], 8, 13) . '...'; $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['siblingReduction'] = $this->event->sibling_reduction ?? true;
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true); $returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
$returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY; $returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY;
+3
View File
@@ -13,6 +13,7 @@ use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository; use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository; use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository; use App\Repositories\ParticipantRefundRepository;
use App\Repositories\PaymentMethodRepository;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
abstract class CommonController { abstract class CommonController {
@@ -27,6 +28,7 @@ abstract class CommonController {
protected EventParticipantRepository $eventParticipants; protected EventParticipantRepository $eventParticipants;
protected ParticipantRefundRepository $participantRefunds; protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates; protected EstimatesRepository $estimates;
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers; protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants; protected AdminTenantRepository $adminTenants;
@@ -40,6 +42,7 @@ abstract class CommonController {
$this->eventParticipants = new EventParticipantRepository(); $this->eventParticipants = new EventParticipantRepository();
$this->participantRefunds = new ParticipantRefundRepository(); $this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository(); $this->estimates = new EstimatesRepository();
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository(); $this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository(); $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, ' ')); 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 * 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. * (A = 10 Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
+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> <script setup>
import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue' import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann // 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 // 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 // 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({ const props = defineProps({
// Ausgewählter Wert (value einer Option). // Ausgewählter Wert (value einer Option).
modelValue: {type: String, default: ''}, 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: () => []}, options: {type: Array, default: () => []},
placeholder: {type: String, default: 'Auswählen…'}, 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 emit = defineEmits(['update:modelValue'])
const open = ref(false) const open = ref(false)
const root = ref(null) const root = ref(null)
const listRef = ref(null) const listRef = ref(null)
const inputRef = ref(null)
const pos = ref({top: 0, left: 0, width: 0}) 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) 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() { function updatePosition() {
if (!root.value) return if (!root.value) return
const rect = root.value.getBoundingClientRect() const rect = root.value.getBoundingClientRect()
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width} pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
} }
async function toggle() { async function openList() {
open.value = !open.value if (open.value) return
if (open.value) { open.value = true
query.value = ''
inputText.value = selected.value?.label ?? ''
highlighted.value = flatOptions.value.findIndex(o => o.value === props.modelValue)
await nextTick() await nextTick()
updatePosition() 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) { function select(option) {
emit('update:modelValue', option.value) emit('update:modelValue', option.value)
open.value = false 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) { function onClickOutside(event) {
const inRoot = root.value && root.value.contains(event.target) const inRoot = root.value && root.value.contains(event.target)
const inList = listRef.value && listRef.value.contains(event.target) const inList = listRef.value && listRef.value.contains(event.target)
if (!inRoot && !inList) { 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) { function onKeydown(event) {
if (event.key === 'Escape' || event.key === 'Esc') { 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() 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(() => { onMounted(() => {
document.addEventListener('click', onClickOutside) document.addEventListener('click', onClickOutside)
document.addEventListener('keydown', onKeydown) document.addEventListener('keydown', onKeydown)
@@ -76,7 +256,26 @@ onBeforeUnmount(() => {
<template> <template>
<div ref="root" class="rich-select"> <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 <button
v-else
type="button" type="button"
class="rich-select__trigger" class="rich-select__trigger"
:aria-expanded="open" :aria-expanded="open"
@@ -100,18 +299,38 @@ onBeforeUnmount(() => {
role="listbox" role="listbox"
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }" :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 <li
v-for="option in options" v-for="option in group.options"
:key="option.value" :key="option.value"
class="rich-select__option" 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" role="option"
:aria-selected="option.value === modelValue" :aria-selected="option.value === modelValue"
@click="select(option)" @click="select(option)"
@mouseenter="highlighted = flatOptions.indexOf(option)"
> >
<Icon v-if="option.icon" :name="option.icon"/> <Icon v-if="option.icon" :name="option.icon" class="rich-select__option-icon"/>
<span>{{ option.label }}</span> <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> </li>
</template>
</ul> </ul>
</Teleport> </Teleport>
</div> </div>
@@ -129,12 +348,12 @@ onBeforeUnmount(() => {
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
gap: 10px; gap: 10px;
padding: 6px 10px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -142,6 +361,22 @@ onBeforeUnmount(() => {
border-color: #2563eb; 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 { .rich-select__value {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -168,26 +403,79 @@ onBeforeUnmount(() => {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 260px; max-height: 320px;
overflow: auto; overflow: auto;
box-sizing: border-box; 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 { .rich-select__option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 10px; padding: 10px 12px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; 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; background: #eff6ff;
} }
.rich-select__option--active { .rich-select__option--active {
background: #dbeafe; background: #dbeafe;
}
.rich-select__option--active .rich-select__option-label {
font-weight: 600; font-weight: 600;
} }
</style> </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']);
});
}
};
+3
View File
@@ -18,6 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<!-- config/postCode.php ist sehr groß und wird je Test neu geladen; mit dem PHP-Default von 128M
reißt die Suite ab einer gewissen Testanzahl das Limit. -->
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
@@ -2,9 +2,9 @@
$rows = $rows ?? []; $rows = $rows ?? [];
@endphp @endphp
"Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat), "Postleitzahl (Stamm)", "Wohnort (Privat)", "Wohnort (Stamm)", "Bundesland (Privat)", "Bundesland (Stamm)", "Alter", "Stamm", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag" "Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat), "Wohnort (Privat)", "Bundesland (Privat)", "Alter", "Postleitzahl (Stamm)", "Wohnort (Stamm)", "Bundesland (Stamm)","Stamm", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag"
@foreach($rows as $index => $row) @foreach($rows as $index => $row)
"{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['city'] }}","{{ $row['localGroupCity'] }}","{{ $row['state'] }}","{{ $row['localGroupState'] }}","{{ $row['age'] }}","{{ $row['localgroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}" "{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['city'] }}","{{ $row['state'] }}","{{ $row['age'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['localGroupCity'] }}","{{ $row['localGroupState'] }}","{{ $row['localgroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}"
@endforeach @endforeach
@@ -24,25 +24,67 @@
@endif @endif
</table> </table>
<p> @if($knownAccountIban !== null)
{{--
Das Konto steht bereits fest: Es ist das, von dem der Beitrag kam, übernommen aus dem
Zahlungseingang. Es wird deshalb weder erfragt noch der Hinweis wiederholt, dass nur dorthin
zurückgezahlt werden darf -- die Frage ist beantwortet. Offen ist nur noch: auszahlen oder spenden.
--}}
<p>
Wir erstatten den Betrag auf das Konto, von dem dein Teilnahmebeitrag gezahlt wurde:
</p>
<p style="padding: 10px 12px; border: 1px solid #dddddd; background-color: #f9f9f9;">
{{$knownAccountOwner}}<br />
<span style="font-family: monospace; letter-spacing: 1px;">{{$knownAccountIban}}</span>
</p>
<p style="font-size: 12px; color: #555;">
Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das Konto nicht zu deiner
Zahlung, wende dich bitte an {{$eventEmail}}.
</p>
<p>
Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann verbleibt er beim Verband
und kommt unserer Arbeit zugute. Bitte sag uns über den folgenden Link kurz Bescheid, wie du es
möchtest:
</p>
<p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Erstattung bestätigen oder spenden</a>
</p>
@else
<p>
Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
folgenden Link ein: folgenden Link ein:
</p> </p>
<p style="padding: 10px 12px; border-left: 3px solid #f5c400; background-color: #fffef5;"> @if($hasOriginAccount)
<p style="padding: 10px 12px; border-left: 3px solid #f5c400; background-color: #fffef5;">
<strong>Wichtig:</strong> Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag <strong>Wichtig:</strong> Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
gezahlt wurde. Wurde er von einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils &ndash;, gezahlt wurde. Wurde er von einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils &ndash;,
gib bitte dieses an. gib bitte dieses an.
</p> </p>
@else
{{--
Ohne Ursprungskonto (bar gezahlt) gäbe es kein Konto, auf das verwiesen werden könnte.
An die Stelle der Herkunft tritt die Kontrolle, die hier greift: das eigene Konto.
--}}
<p style="padding: 10px 12px; border-left: 3px solid #f5c400; background-color: #fffef5;">
<strong>Wichtig:</strong> Bitte gib ein Konto an, das auf deinen Namen läuft oder über das du
verfügen darfst. Auf ein fremdes Konto können wir nicht erstatten.
</p>
@endif
<p> <p>
Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine
Bankverbindung von dir; die Auswahl findest du hinter demselben Link. Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
</p> </p>
<p> <p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen oder spenden</a> <a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen oder spenden</a>
</p> </p>
@endif
<p style="font-size: 12px; color: #555;"> <p style="font-size: 12px; color: #555;">
Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br /> Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br />
+343
View File
@@ -0,0 +1,343 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Mail\ParticipantPaymentMails\ParticipantPaymentMissingPaymentMail;
use App\Mail\ParticipantPaymentMails\ParticipantPaymentPaidMail;
use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventPaymentMethods;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Zahlungsimport von der hochgeladenen Datei bis zur gebuchten Zahlung.
*/
class BankStatementImportTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private Event $event;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
$this->event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
AvailablePaymentMethod::create([
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'name' => 'Überweisung', 'active' => true, 'configuration' => $configuration,
]);
EventPaymentMethods::create([
'event_id' => $this->event->id,
'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'configuration' => $configuration,
]);
}
private function participant(
string $identifier,
string $firstname,
string $lastname,
float $amount = 120.0,
float $amountPaid = 0.0,
?string $lastPaymentDate = null,
?string $unregisteredAt = null,
): EventParticipant {
return EventParticipant::create([
'tenant' => $this->tenant->slug, 'event_id' => $this->event->id, 'identifier' => $identifier,
'firstname' => $firstname, 'lastname' => $lastname,
'participation_type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '00000', 'city' => 'Stadt',
'email_1' => strtolower($firstname) . '@example.com', 'phone_1' => '123',
'arrival_date' => '2026-08-01', 'departure_date' => '2026-08-05',
'arrival_eating' => 0, 'departure_eating' => 0,
'amount' => $amount, 'amount_paid' => $amountPaid,
'payment_purpose' => "Sommerlager - Beitrag {$firstname} {$lastname}",
'efz_status' => EfzStatus::EFZ_STATUS_NOT_CHECKED,
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
'last_payment_date' => $lastPaymentDate,
'unregistered_at' => $unregisteredAt,
]);
}
private function upload(string $csv, string $name = 'Umsaetze.csv'): UploadedFile
{
return UploadedFile::fake()->createWithContent($name, $csv);
}
private function parse(UploadedFile $file): \App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementResponse
{
return new ParseBankStatementCommand(new ParseBankStatementRequest(
event: $this->event,
file: $file,
configuration: (new \App\Repositories\PaymentMethodRepository())
->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION),
))->execute();
}
public function test_parse_suggests_the_matching_participant(): void
{
$this->participant('p-1', 'Max', 'Meier');
$this->participant('p-2', 'Lena', 'Kunze');
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n"
. "09.09.2026;09.09.2026;ACME GmbH;DE02120300000000202051;Rechnung 4711;60,00\r\n"
// Belastung: keine Zahlung an die Aktion, taucht gar nicht erst auf.
. "09.09.2026;09.09.2026;Zeltplatz;DE02120300000000202051;Miete;-300,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertTrue($response->success);
$this->assertCount(2, $response->rows);
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
$this->assertSame('sicher', $response->rows[0]['confidence']);
$this->assertNull($response->rows[1]['suggestedIdentifier']);
// Die Auswahlliste trägt alle Anmeldungen, auch die ohne Vorschlag -- nach Nachname sortiert,
// damit sich in einem Lager mit achtzig Namen etwas finden lässt.
$this->assertSame(
['Kunze, Lena', 'Meier, Max'],
array_column($response->participants, 'name'),
);
}
/**
* Wer den Beitrag überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Bliebe
* die Anmeldung hier draußen, wäre der Eingang nicht zuordenbar und das Geld läge unbemerkt auf
* dem Konto -- erstatten lässt sich erst, was erfasst ist.
*/
public function test_parse_includes_signed_off_participants(): void
{
$this->participant('p-1', 'Max', 'Meier', unregisteredAt: '2026-07-15');
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
. "09.09.2026;Sommerlager - Beitrag Max Meier;120,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertCount(1, $response->rows);
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
// Die Prüfansicht muss die Abmeldung zeigen, sonst bucht jemand blind.
$this->assertTrue($response->participants[0]['isSignedOff']);
$this->assertSame('15.07.2026', $response->participants[0]['signedOffAt']);
}
public function test_booking_works_for_a_signed_off_participant(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, unregisteredAt: '2026-07-15');
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 120.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Sommerlager - Beitrag Max Meier',
]],
))->execute();
$this->assertSame(1, $response->booked);
$participant->refresh();
$this->assertSame(120.0, $participant->amount_paid->getAmount());
// Damit ist die Grundlage für die Erstattung da: Konto bekannt, Betrag erfasst.
$this->assertTrue($participant->refund_data);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
}
public function test_parse_hides_payments_older_than_the_watermark(): void
{
$this->participant('p-1', 'Max', 'Meier', lastPaymentDate: '2026-09-09');
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
// Älter als die zuletzt erfasste Zahlung -- bereits gebucht.
. "05.09.2026;Sommerlager - Beitrag Max Meier;40,00\r\n"
// Gleicher Tag: eine zweite Rate am selben Tag soll durchkommen.
. "09.09.2026;Sommerlager - Beitrag Max Meier;30,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertCount(1, $response->rows);
$this->assertSame(1, $response->skippedOlderThanWatermark);
$this->assertSame(30.0, $response->rows[0]['amount']);
}
public function test_parse_reports_a_wrong_file(): void
{
$response = $this->parse($this->upload("Spalte A;Spalte B\r\n1;2\r\n"));
$this->assertFalse($response->success);
$this->assertStringContainsString('Buchungstag', $response->message);
}
public function test_parse_rejects_a_non_csv_upload(): void
{
$response = $this->parse($this->upload('irgendwas', 'auszug.pdf'));
$this->assertFalse($response->success);
$this->assertStringContainsString('CSV', $response->message);
}
public function test_booking_adds_to_the_paid_amount_and_records_the_account(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0);
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02 1203 0000 0000 2020 51',
'purpose' => 'Sommerlager - Beitrag Max Meier',
]],
))->execute();
$this->assertTrue($response->success);
$this->assertSame(1, $response->booked);
$participant->refresh();
// Aufaddiert, nicht überschrieben: 40 + 80.
$this->assertSame(120.0, $participant->amount_paid->getAmount());
$this->assertTrue($participant->refund_data);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertSame('2026-09-09', $participant->last_payment_date->format('Y-m-d'));
Mail::assertSent(ParticipantPaymentPaidMail::class);
}
public function test_booking_a_partial_payment_asks_for_the_rest(): void
{
$this->participant('p-1', 'Max', 'Meier', amount: 120.0);
new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 50.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Anzahlung',
]],
))->execute();
Mail::assertSent(ParticipantPaymentMissingPaymentMail::class);
}
/**
* In der Prüfansicht lässt sich eine Zeile von Hand einer anderen Person zuordnen -- deren
* Wasserzeichen hat der erste Durchlauf nie gesehen. Deshalb wird serverseitig noch einmal geprüft.
*/
public function test_booking_skips_a_payment_older_than_the_watermark(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amountPaid: 40.0, lastPaymentDate: '2026-09-09');
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-01',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Alte Zahlung',
]],
))->execute();
$this->assertSame(0, $response->booked);
$this->assertSame(1, $response->skipped);
$participant->refresh();
$this->assertSame(40.0, $participant->amount_paid->getAmount());
Mail::assertNothingSent();
}
/** Der Identifier kommt aus dem Browser -- eine fremde Anmeldung darf nicht gebucht werden. */
public function test_booking_rejects_a_participant_from_another_event(): void
{
$otherEvent = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Herbstfahrt', 'identifier' => 'evt-2', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-10-01', 'end_date' => '2026-10-05',
'early_bird_end' => '2026-09-01', 'registration_final_end' => '2026-09-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$foreign = $this->participant('p-1', 'Max', 'Meier');
$foreign->event_id = $otherEvent->id;
$foreign->save();
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Beitrag',
]],
))->execute();
$this->assertSame(0, $response->booked);
$this->assertSame(1, $response->failed);
$foreign->refresh();
$this->assertSame(0.0, $foreign->amount_paid->getAmount());
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\CreateEvent\CreateEventCommand;
use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipationFeeType;
use App\Models\CostUnit;
use App\Models\Event;
use App\Models\Tenant;
use App\Resources\EventResource;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class EventAddressTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'tv',
'name' => 'Test',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => 'test.local',
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
'tax_liable' => false,
'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive',
]);
app()->instance('tenant', $this->tenant);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
}
private function createEvent(?string $street = null, ?string $houseNumber = null): Event
{
$response = new CreateEventCommand(new CreateEventRequest(
name: 'Sommerlager',
location: 'Leipzig',
postalCode: '04103',
email: 'e@example.com',
begin: new DateTime('2026-09-01'),
end: new DateTime('2026-09-05'),
earlyBirdEnd: new DateTime('2026-08-20'),
registrationFinalEnd: new DateTime('2026-08-25'),
earlyBirdEndAmountIncrease: 0,
participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(),
accountOwner: 'Owner',
accountIban: 'DE00',
payPerDay: false,
street: $street,
houseNumber: $houseNumber,
))->execute();
$this->assertTrue($response->success);
return $response->event->fresh();
}
private function updateEvent(Event $event, ?string $street, ?string $houseNumber): Event
{
$response = new UpdateEventCommand(new UpdateEventRequest(
event: $event,
eventName: $event->name,
eventLocation: $event->location,
postalCode: $event->postal_code,
email: $event->email,
earlyBirdEnd: new DateTime('2026-08-20'),
registrationFinalEnd: new DateTime('2026-08-25'),
alcoholicsAge: 16,
sendWeeklyReports: true,
registrationAllowed: true,
flatSupport: Amount::fromString('0,00'),
supportPerPerson: Amount::fromString('0,00'),
contributingLocalGroups: [],
eatingHabits: [],
street: $street,
houseNumber: $houseNumber,
))->execute();
$this->assertTrue($response->success);
return $event->fresh();
}
public function test_anlage_speichert_strasse_und_hausnummer(): void
{
$event = $this->createEvent('Musterweg', '12');
$this->assertSame('Musterweg', $event->street);
$this->assertSame('12', $event->house_number);
}
public function test_anlage_ohne_angabe_laesst_beide_felder_leer(): void
{
$event = $this->createEvent();
$this->assertNull($event->street);
$this->assertNull($event->house_number);
}
public function test_leere_eingaben_werden_bei_der_anlage_zu_null(): void
{
$event = $this->createEvent('', ' ');
$this->assertNull($event->street);
$this->assertNull($event->house_number);
}
public function test_einstellungen_ergaenzen_und_leeren_die_anschrift(): void
{
$event = $this->updateEvent($this->createEvent(), 'Musterweg', '12');
$this->assertSame('Musterweg', $event->street);
$this->assertSame('12', $event->house_number);
$event = $this->updateEvent($event, '', '');
$this->assertNull($event->street);
$this->assertNull($event->house_number);
}
public function test_anschrift_enthaelt_strasse_nur_wenn_sie_gefuellt_ist(): void
{
$this->assertSame('Musterweg 12, 04103 Leipzig', $this->createEvent('Musterweg', '12')->getFullAddress());
$this->assertSame('04103 Leipzig', $this->createEvent()->getFullAddress());
}
public function test_resource_liefert_einzelfelder_und_fertige_anschrift(): void
{
$event = $this->createEvent('Musterweg', '12');
// Die Resource gibt auch die Kostenstelle aus; im Betrieb legt der Controller sie direkt nach
// der Veranstaltung an.
$event->cost_unit_id = CostUnit::create([
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'distance_allowance' => 0.25,
'mail_on_new' => false,
'allow_new' => true,
'archived' => false,
])->id;
$event->save();
$resource = new EventResource($event)->toArray(new Request());
$this->assertSame('Musterweg', $resource['street']);
$this->assertSame('12', $resource['houseNumber']);
$this->assertSame('Musterweg 12, 04103 Leipzig', $resource['fullAddress']);
}
}
+347
View File
@@ -0,0 +1,347 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PageText;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\Providers\DocumentTemplateRenderProvider;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use ReflectionMethod;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Erstattung an jemanden, der bar gezahlt hat.
*
* Es gibt kein Ursprungskonto -- die Frage danach wäre sinnlos und die Erklärung, es sei dasselbe
* Konto, unwahr. Erstattet wird trotzdem per Überweisung: auf ein Konto, das der Person gehört.
*/
class RefundCashPayerTest extends TestCase
{
use RefreshDatabase;
private const string OWN_IBAN = 'DE02120300000000202051';
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1',
'email' => 't@example.com', 'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'invoice_prefix' => 'WM',
'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
// Beide Kontoerklärungen legen die Migrationen bereits an. Hier bekommen sie einen
// unverwechselbaren Wortlaut, damit die Tests sehen, welche der beiden gezogen wurde -- und
// zwar unabhängig davon, wie die ausgelieferten Sätze gerade formuliert sind.
PageText::where('name', CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT)
->update(['content' => 'Erklärung Ursprungskonto.']);
PageText::where('name', CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT)
->update(['content' => 'Erklärung eigenes Konto.']);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{declaration_text}</p>',
'sort_order' => 20,
]);
$this->actingAs($this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN));
Storage::fake('local');
Mail::fake();
}
private function makeUser(string $firstname, string $lastname, string $role): User
{
return User::create([
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'firstname' => $firstname, 'lastname' => $lastname, 'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug, 'user_role_main' => $role,
'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true,
]);
}
private function makeEvent(): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => 'participant', 'name' => 'Sippe',
'description' => null, 'amount_standard' => 60.0,
'amount_reduced' => null, 'amount_solidarity' => null,
]);
$costUnit = CostUnit::create([
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT, 'distance_allowance' => 0.25,
'mail_on_new' => false, 'allow_new' => true, 'archived' => false,
]);
return Event::create([
'cost_unit_id' => $costUnit->id, 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(), 'location' => 'Ort', 'postal_code' => '00000',
'email' => 'e@example.com', 'start_date' => '2026-07-16', 'end_date' => '2026-07-20',
'early_bird_end' => '2026-06-20', 'registration_final_end' => '2026-07-01',
'early_bird_end_amount_increase' => 0, 'account_owner' => 'Owner', 'account_iban' => 'DE00',
'participation_fee_type' => 'fixed', 'participation_fee_1' => $fee->id,
'pay_per_day' => true, 'pay_direct' => false, 'tax_liable' => false, 'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701',
]);
}
private function makeParticipant(string $paymentMethod = PaymentMethod::PAYMENT_NOT_DEFINED): EventParticipant
{
$this->sequence++;
return $this->makeEvent()->participants()->create([
'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
'firstname' => 'Mika', 'lastname' => 'Muster', 'participation_type' => 'participant',
'fee_type' => 'standard', 'sibling_reduction' => false,
'local_group' => $this->tenant->slug, 'birthday' => '2000-01-01',
'address_1' => 'Beispielstraße 3', 'postcode' => '11111', 'city' => 'Beispielstadt',
'email_1' => 'mika@example.com', 'phone_1' => '0170 0000000',
'arrival_date' => '2026-07-16', 'departure_date' => '2026-07-20',
'arrival_eating' => 1, 'departure_eating' => 1,
'amount' => 300.0, 'amount_paid' => 300.0, 'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => $paymentMethod,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]);
}
private function release(EventParticipant $participant): ParticipantRefund
{
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(300.0, 'Euro'),
reason: RefundReason::SICKNESS,
))->execute();
$this->assertTrue($response->success, $response->message);
return $response->refund;
}
/*
|--------------------------------------------------------------------------
| Die Erstattungsseite
|--------------------------------------------------------------------------
*/
public function test_the_page_does_not_ask_for_an_origin_account(): void
{
$refund = $this->release($this->makeParticipant());
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page
->where('state', 'open')
->where('accountSource', 'none')
->where('knownAccount', null)
// Die Erklärung, die zur Barzahlung passt -- nicht die über das Ursprungskonto.
->where('accountDeclarationText', CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT));
}
/** Gegenprobe: Überweiser ohne erfassten Zahlungseingang bekommen weiterhin die Herkunftsfrage. */
public function test_a_transfer_payer_still_gets_the_origin_question(): void
{
$refund = $this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page
->where('accountSource', 'origin')
->where('accountDeclarationText', CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT));
}
/*
|--------------------------------------------------------------------------
| Die Freigabe-Mail
|--------------------------------------------------------------------------
*/
public function test_the_release_mail_asks_for_an_own_account(): void
{
$this->release($this->makeParticipant());
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
$rendered = $mail->render();
// Der Hinweis auf das Ursprungskonto wäre hier falsch -- es gab keines.
$this->assertStringNotContainsString('von dem der Teilnahmebeitrag', $rendered);
$this->assertStringContainsString('auf deinen Namen läuft', $rendered);
return true;
});
}
public function test_the_release_mail_keeps_the_origin_hint_for_transfer_payers(): void
{
$this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
$this->assertStringContainsString('von dem der Teilnahmebeitrag', $mail->render());
return true;
});
}
/*
|--------------------------------------------------------------------------
| Bestätigung und Beleg
|--------------------------------------------------------------------------
*/
public function test_the_cash_payer_can_name_an_account_and_the_refund_completes(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
$response = $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'donation' => false,
'accountOwner' => 'Mika Muster',
'accountIban' => self::OWN_IBAN,
'declarationAccepted' => true,
'accountDeclarationAccepted' => true,
]);
$response->assertOk();
$response->assertJsonPath('status', 'success');
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertSame(self::OWN_IBAN, $refund->account_iban);
$this->assertSame('Mika Muster', $refund->account_owner);
// Die Abrechnung entsteht wie bei jeder anderen Erstattung.
$this->assertNotNull($refund->invoice_id);
}
/**
* Der Beleg darf dem Teili nicht zuschreiben, er habe bestätigt, es sei das Konto der
* Ursprungszahlung -- die Frage wurde ihm nie gestellt.
*/
public function test_the_receipt_carries_the_own_account_declaration(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: self::OWN_IBAN,
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$html = $this->receiptHtml($refund->fresh());
$this->assertStringContainsString('Erklärung eigenes Konto.', $html);
$this->assertStringNotContainsString('Erklärung Ursprungskonto.', $html);
}
public function test_the_receipt_of_a_transfer_payer_is_unchanged(): void
{
$refund = $this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: self::OWN_IBAN,
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$html = $this->receiptHtml($refund->fresh());
$this->assertStringContainsString('Erklärung Ursprungskonto.', $html);
$this->assertStringNotContainsString('Erklärung eigenes Konto.', $html);
}
/** Die Spende funktioniert für Barzahler unverändert -- dort gibt es ohnehin kein Konto. */
public function test_a_cash_payer_can_donate(): void
{
$refund = $this->release($this->makeParticipant());
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
donation: true,
declarationAccepted: true,
))->execute();
$this->assertTrue($response->success, $response->message);
$refund->refresh();
$this->assertNull($refund->account_iban);
$this->assertTrue($refund->isDonation());
}
/**
* Der Beleg als HTML, vor dem PDF-Satz.
*
* Wie in {@see RefundDocumentTest::html()}: `execute()` liefert nur das fertige PDF, und in dessen
* komprimierten Streams nach Text zu suchen wäre unzuverlässig. Stattdessen dieselben Tokens durch
* denselben Render-Provider, den der Command auch benutzt.
*/
private function receiptHtml(ParticipantRefund $refund): string
{
$command = new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: false)
);
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
}
}
+375
View File
@@ -0,0 +1,375 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Erstattung auf das Konto, das der Zahlungsimport bereits kennt.
*
* Der Teili tippt dann keine IBAN mehr ab -- er entscheidet nur noch, ob er den Betrag haben oder
* spenden möchte. Wo nichts bekannt ist (Altbestand, Barzahlung), bleibt alles wie zuvor.
*/
class RefundKnownAccountTest extends TestCase
{
use RefreshDatabase;
private const string KNOWN_IBAN = 'DE02120300000000202051';
private const string KNOWN_OWNER = 'Mika Muster';
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1',
'email' => 't@example.com', 'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'invoice_prefix' => 'WM',
'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{declaration_text}</p>',
'sort_order' => 20,
]);
$this->actingAs($this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN));
Storage::fake('local');
Mail::fake();
}
private function makeUser(string $firstname, string $lastname, string $role): User
{
return User::create([
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'firstname' => $firstname, 'lastname' => $lastname, 'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug, 'user_role_main' => $role,
'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true,
]);
}
private function makeEvent(): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => 'participant', 'name' => 'Sippe',
'description' => null, 'amount_standard' => 60.0,
'amount_reduced' => null, 'amount_solidarity' => null,
]);
$costUnit = CostUnit::create([
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT, 'distance_allowance' => 0.25,
'mail_on_new' => false, 'allow_new' => true, 'archived' => false,
]);
return Event::create([
'cost_unit_id' => $costUnit->id, 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(), 'location' => 'Ort', 'postal_code' => '00000',
'email' => 'e@example.com', 'start_date' => '2026-07-16', 'end_date' => '2026-07-20',
'early_bird_end' => '2026-06-20', 'registration_final_end' => '2026-07-01',
'early_bird_end_amount_increase' => 0, 'account_owner' => 'Owner', 'account_iban' => 'DE00',
'participation_fee_type' => 'fixed', 'participation_fee_1' => $fee->id,
'pay_per_day' => true, 'pay_direct' => false, 'tax_liable' => false, 'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701',
]);
}
/** Standardfall: abgemeldet, voll gezahlt, Konto aus dem Zahlungseingang bekannt. */
private function makeParticipant(array $attributes = []): EventParticipant
{
$this->sequence++;
return $this->makeEvent()->participants()->create(array_merge([
'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
'firstname' => 'Mika', 'lastname' => 'Muster', 'participation_type' => 'participant',
'fee_type' => 'standard', 'sibling_reduction' => false,
'local_group' => $this->tenant->slug, 'birthday' => '2000-01-01',
'address_1' => 'Beispielstraße 3', 'postcode' => '11111', 'city' => 'Beispielstadt',
'email_1' => 'mika@example.com', 'phone_1' => '0170 0000000',
'arrival_date' => '2026-07-16', 'departure_date' => '2026-07-20',
'arrival_eating' => 1, 'departure_eating' => 1,
'amount' => 300.0, 'amount_paid' => 300.0, 'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
// Was der Zahlungsimport hinterlassen hat.
'payment_options' => [
AccountTransferPaymentModule::OPTION_PAYER_IBAN => self::KNOWN_IBAN,
AccountTransferPaymentModule::OPTION_PAYER_ACCOUNT_OWNER => self::KNOWN_OWNER,
],
'refund_data' => true,
'last_payment_date' => '2026-05-02',
], $attributes));
}
private function release(EventParticipant $participant): ParticipantRefund
{
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(300.0, 'Euro'),
reason: RefundReason::SICKNESS,
))->execute();
$this->assertTrue($response->success, $response->message);
return $response->refund;
}
/*
|--------------------------------------------------------------------------
| Freigabe
|--------------------------------------------------------------------------
*/
public function test_the_known_account_is_stored_but_the_refund_stays_pending(): void
{
$refund = $this->release($this->makeParticipant());
// Offen, weil der Teili noch entscheiden soll: auszahlen oder spenden.
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
public function test_a_participant_without_payment_data_gets_no_account(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertNull($refund->account_owner);
}
/** Barzahlung kennt kein Konto -- auch dann, wenn zufällig etwas in den Optionen steht. */
public function test_cash_payment_gets_no_account(): void
{
$refund = $this->release($this->makeParticipant([
'payment_method' => PaymentMethod::PAYMENT_NOT_DEFINED,
]));
$this->assertNull($refund->account_iban);
}
/*
|--------------------------------------------------------------------------
| Was nach draußen geht
|--------------------------------------------------------------------------
*/
public function test_the_release_mail_shows_the_iban_only_masked(): void
{
$participant = $this->makeParticipant();
$this->release($participant);
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
$rendered = $mail->render();
$this->assertStringContainsString('DE02 •••• •••• •••• ••20 51', $rendered);
// Die vollständige IBAN liegt sonst in einem Postfach.
$this->assertStringNotContainsString(self::KNOWN_IBAN, $rendered);
$this->assertStringNotContainsString('DE02 1203 0000 0000 2020 51', $rendered);
// Nach der Bankverbindung wird nicht mehr gefragt, nach der Spende schon.
$this->assertStringNotContainsString('brauchen wir noch deine Bankverbindung', $rendered);
$this->assertStringContainsString('spenden', $rendered);
return true;
});
}
public function test_the_public_page_never_exposes_the_full_iban(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page
->where('state', 'open')
->where('knownAccount.owner', self::KNOWN_OWNER)
->where('knownAccount.ibanMasked', 'DE02 •••• •••• •••• ••20 51'));
// Und zwar nirgends in der Antwort -- auch nicht in einem Feld, das niemand liest.
$this->assertStringNotContainsString(
self::KNOWN_IBAN,
$this->get('/rueckerstattung/' . $refund->token)->getContent(),
);
}
public function test_the_public_page_asks_for_the_account_when_nothing_is_known(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
// Kein Konto zum Anzeigen -- das Formular bleibt, wie es war.
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'open')->where('knownAccount', null));
}
/*
|--------------------------------------------------------------------------
| Bestätigung
|--------------------------------------------------------------------------
*/
public function test_the_participant_only_confirms_and_the_account_stands(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
// Ohne Kontofelder -- genau das schickt die Seite bei bekanntem Konto.
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$this->assertTrue($response->success, $response->message);
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
/**
* Der eigentliche Gewinn: Wer den Token hat, kann die Auszahlung nicht auf ein fremdes Konto
* umbiegen. Das Konto steht seit der Freigabe fest.
*/
public function test_a_foreign_iban_in_the_request_is_ignored(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
$response = $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'donation' => false,
'accountOwner' => 'Fremde Person',
'accountIban' => 'DE89370400440532013000',
'declarationAccepted' => true,
'accountDeclarationAccepted' => true,
]);
$response->assertOk();
$response->assertJsonPath('status', 'success');
$refund->refresh();
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
/** Die Erklärungen bleiben Pflicht -- der Beleg zitiert sie. */
public function test_the_declarations_are_still_required(): void
{
$refund = $this->release($this->makeParticipant());
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
declarationAccepted: false,
accountDeclarationAccepted: false,
))->execute();
$this->assertFalse($response->success);
$this->assertArrayHasKey('declaration', $response->errorTypes);
$this->assertArrayHasKey('accountDeclaration', $response->errorTypes);
// Und keine Meckerei über Felder, die gar nicht mehr gezeigt werden.
$this->assertArrayNotHasKey('accountIban', $response->errorTypes);
$this->assertArrayNotHasKey('accountOwner', $response->errorTypes);
}
/**
* Wer spendet, bekommt nichts überwiesen -- die Kontofelder müssen leer werden, sonst zöge der
* SEPA-Export eine Auszahlung, die niemand wollte.
*/
public function test_a_donation_clears_the_known_account(): void
{
$refund = $this->release($this->makeParticipant());
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
donation: true,
declarationAccepted: true,
))->execute();
$this->assertTrue($response->success, $response->message);
$refund->refresh();
$this->assertNull($refund->account_iban);
$this->assertNull($refund->account_owner);
$this->assertTrue($refund->isDonation());
}
/** Ohne bekanntes Konto bleibt die Eingabe Pflicht wie eh und je. */
public function test_without_a_known_account_the_fields_are_still_required(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountOwner', $response->errorTypes);
$this->assertArrayHasKey('accountIban', $response->errorTypes);
}
}
+386
View File
@@ -0,0 +1,386 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ShortSignUpTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_DENIED, 'name' => 'Darf nicht baden', 'short' => 'Keine']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_DENIED, 'name' => 'Abgelehnt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
}
private function createEvent(
bool $withFee = true,
bool $swimmingPermissionRequired = true,
?float $amountReduced = 30.0,
?float $amountSolidarity = 70.0,
): Event
{
$event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
'short_registration' => true, 'swimming_permission_required' => $swimmingPermissionRequired,
]);
if ($withFee) {
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0,
'amount_reduced' => $amountReduced, 'amount_solidarity' => $amountSolidarity,
]);
$event->participation_fee_1 = $fee->id;
$event->save();
$event->refresh();
}
return $event;
}
private function shortRequest(Event $event, array $overrides = []): ShortSignUpRequest
{
return new ShortSignUpRequest(
event: $event,
user_id: null,
firstname: $overrides['firstname'] ?? 'Hans',
lastname: $overrides['lastname'] ?? 'Muster',
nickname: $overrides['nickname'] ?? null,
// Volljährig, sofern nicht anders angegeben.
birthday: $overrides['birthday'] ?? new \DateTime('2000-01-01'),
email: $overrides['email'] ?? 'h@example.com',
phone: $overrides['phone'] ?? '123',
localGroupId: $overrides['localGroupId'] ?? null,
contactPerson: $overrides['contactPerson'] ?? null,
contactEmail: $overrides['contactEmail'] ?? null,
allergies: $overrides['allergies'] ?? null,
intolerances: $overrides['intolerances'] ?? null,
firstAidPermission: $overrides['firstAidPermission'] ?? null,
swimmingPermission: $overrides['swimmingPermission'] ?? null,
foto_socialmedia: true,
foto_print: false,
foto_webseite: false,
foto_partner: false,
foto_intern: false,
paymentMethod: $overrides['paymentMethod'] ?? PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
paymentOptions: [],
participationOptions: $overrides['participationOptions'] ?? [],
feeType: $overrides['feeType'] ?? 'standard',
);
}
public function test_kurzanmeldung_fuellt_nicht_erhobene_pflichtfelder(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$participant = $response->participant;
$this->assertSame('Nicht erforderlich', $participant->address_1);
$this->assertSame('Nicht erforderlich', $participant->city);
$this->assertSame('00000', $participant->postcode);
$this->assertNull($participant->local_group);
$this->assertNull($participant->address_2);
$this->assertNull($participant->medications);
// Zeitraum und Mahlzeiten kommen aus der Veranstaltung.
$this->assertSame('2026-08-01', $participant->arrival_date->format('Y-m-d'));
$this->assertSame('2026-08-05', $participant->departure_date->format('Y-m-d'));
$this->assertSame(1, $participant->arrival_eating);
$this->assertSame(2, $participant->departure_eating);
// Die Gruppe steht fest, ohne Wahl gilt der Standardbeitrag.
$this->assertSame(ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $participant->participation_type);
$this->assertSame('standard', $participant->fee_type);
$this->assertSame(50.0, $participant->amount->getAmount());
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $participant->eating_habit);
// Volljährige brauchen keine Erlaubnisse.
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, $participant->swimming_permission);
$this->assertSame(FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, $participant->first_aid_permission);
$this->assertSame(1, $participant->invoice_sequence);
$this->assertTrue($participant->foto_socialmedia);
$this->assertFalse($participant->foto_print);
}
public function test_gewaehlter_reduzierter_beitrag_wird_berechnet(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'reduced']))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('reduced', $response->participant->fee_type);
$this->assertSame(30.0, $response->participant->amount->getAmount());
}
public function test_gewaehlter_solidaritaetsbeitrag_wird_berechnet(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'solidarity']))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('solidarity', $response->participant->fee_type);
$this->assertSame(70.0, $response->participant->amount->getAmount());
}
public function test_nicht_hinterlegte_beitragsstufe_wird_abgelehnt(): void
{
$event = $this->createEvent(amountReduced: null);
$response = new ShortSignUpCommand($this->shortRequest($event, ['feeType' => 'reduced']))->execute();
$this->assertFalse($response->success);
$this->assertNotNull($response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_waehlbar_sind_nur_hinterlegte_beitragsstufen(): void
{
$this->assertSame(
['standard', 'reduced', 'solidarity'],
$this->createEvent()->firstParticipationFee()->availableFeeTypes()
);
$withoutReduced = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0,
'amount_reduced' => null, 'amount_solidarity' => 70.0,
]);
$this->assertSame(['standard', 'solidarity'], $withoutReduced->availableFeeTypes());
}
public function test_u18_ohne_abgefragte_badeerlaubnis_bekommt_keine(): void
{
$event = $this->createEvent(swimmingPermissionRequired: false);
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
'contactPerson' => 'Muster, Maria',
'contactEmail' => 'maria@example.com',
'firstAidPermission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_DENIED, $response->participant->swimming_permission);
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $response->participant->efz_status);
$this->assertSame('Muster, Maria', $response->participant->contact_person);
$this->assertSame('maria@example.com', $response->participant->email_2);
}
public function test_u18_uebernimmt_gewaehlte_badeerlaubnis(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
'contactPerson' => 'Muster, Maria',
'contactEmail' => 'maria@example.com',
'firstAidPermission' => FirstAidPermission::FIRST_AID_PERMISSION_DENIED,
'swimmingPermission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, $response->participant->swimming_permission);
$this->assertSame(FirstAidPermission::FIRST_AID_PERMISSION_DENIED, $response->participant->first_aid_permission);
}
public function test_u18_ohne_kontaktperson_wird_abgelehnt(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
]))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_ohne_teilnahmegruppe_wird_abgelehnt(): void
{
$event = $this->createEvent(withFee: false);
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertNotNull($response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_teilnahmeoptionen_werden_gespeichert(): void
{
$event = $this->createEvent();
$event->participation_options = [[
'key' => 'shirt',
'title' => 'T-Shirt',
'label' => 'T-Shirt-Größe',
'options' => [['value' => 'm', 'label' => 'M'], ['value' => 'l', 'label' => 'L']],
]];
$event->save();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'participationOptions' => ['shirt' => 'l'],
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$options = $response->participant->selectedOptions()->get();
$this->assertCount(1, $options);
$this->assertSame('shirt', $options->first()->question_key);
$this->assertSame('L', $options->first()->value_label);
}
public function test_unbeantwortete_teilnahmeoption_wird_abgelehnt(): void
{
$event = $this->createEvent();
$event->participation_options = [[
'key' => 'shirt',
'title' => 'T-Shirt',
'label' => 'T-Shirt-Größe',
'options' => [['value' => 'm', 'label' => 'M']],
]];
$event->save();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
private function createLocalGroup(string $slug, string $name): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
'email_finance' => $slug . '-f@example.com', 'url' => $slug . '.local',
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => false,
]);
}
public function test_einziger_stamm_wird_ohne_auswahl_zugeordnet(): void
{
$event = $this->createEvent();
$group = $this->createLocalGroup('stamm-a', 'Stamm A');
$event->localGroups()->attach($group->id);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('stamm-a', $response->participant->local_group);
}
public function test_bei_mehreren_staemmen_wird_die_auswahl_uebernommen(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $second->id,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('stamm-b', $response->participant->local_group);
}
public function test_bei_mehreren_staemmen_ist_die_auswahl_pflicht(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_nicht_teilnehmender_stamm_wird_abgelehnt(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
// Stamm existiert, nimmt aber nicht an der Veranstaltung teil.
$foreign = $this->createLocalGroup('stamm-c', 'Stamm C');
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $foreign->id,
]))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_resource_ueberlebt_kurzanmeldung(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
// Regressionswächter für Teilnehmerliste, Detailansicht und CSV-Export.
$array = $response->participant->toResource()->toArray(request());
$this->assertSame('Nicht im LV', $array['localgroup']);
$this->assertSame('Erteilt', $array['swimmingPermission']);
$this->assertSame('Zugestimmt', $array['extendedFirstAid']);
$this->assertSame('Omnivor', $array['eatingHabit']);
$this->assertSame('Teilnehmende', $array['participationType']);
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use PHPUnit\Framework\TestCase;
/**
* Die drei zahlartspezifischen Entscheidungen des Überweisungs-Moduls: Was zählt, wem es gehört, was
* nachgetragen wird. Läuft ohne Datenbank -- die Kandidaten werden hereingereicht, das Modul fragt
* selbst nichts ab.
*/
class BankStatementMatchTest extends TestCase
{
private function participant(
string $identifier,
string $firstname,
string $lastname,
string $purpose = '',
float $amount = 120.0,
float $amountPaid = 0.0,
array $paymentOptions = [],
): EventParticipant {
$participant = new EventParticipant();
$participant->setRawAttributes([
'identifier' => $identifier,
'firstname' => $firstname,
'lastname' => $lastname,
'payment_purpose' => $purpose !== '' ? $purpose : "Sommerlager - Beitrag {$firstname} {$lastname}",
], true);
$participant->amount = new Amount($amount, 'Euro');
$participant->amount_paid = new Amount($amountPaid, 'Euro');
$participant->payment_options = $paymentOptions;
return $participant;
}
private function transaction(
string $purpose,
float $amount = 120.0,
string $payerName = '',
string $payerIban = '',
): BankTransaction {
return new BankTransaction(
paymentDate: CarbonImmutable::create(2026, 9, 9),
amount: new Amount($amount, 'Euro'),
purpose: $purpose,
payerName: $payerName,
payerIban: $payerIban,
rowNumber: 2,
);
}
/** @param array<int, EventParticipant> $participants */
private function candidates(array $participants): Collection
{
return new Collection($participants);
}
public function test_only_credits_are_relevant_for_a_transfer(): void
{
$module = new AccountTransferPaymentModule();
$this->assertTrue($module->isRelevantTransaction($this->transaction('Beitrag', 120.0)));
$this->assertFalse($module->isRelevantTransaction($this->transaction('Erstattung', -120.0)));
}
/** Regel 1: der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug. */
public function test_matches_the_generated_payment_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Lena', 'Kunze'),
]);
$match = $module->matchTransaction($this->transaction('SOMMERLAGER - BEITRAG MAX MEIER'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
$this->assertSame(TransactionMatch::CONFIDENCE_CERTAIN, $match->confidence);
}
/** Regel 2: abgetippter Zweck, aber beide Namen sind noch drin -- auch mit Umlaut. */
public function test_matches_both_names_in_a_retyped_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Jörg', 'Müller')]);
$match = $module->matchTransaction($this->transaction('Beitrag fuer Joerg Mueller, Lager'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 3: Folgezahlung vom Konto, von dem schon einmal etwas kam. */
public function test_matches_a_follow_up_payment_by_known_iban(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', paymentOptions: ['payer_iban' => 'DE02120300000000202051']),
$this->participant('b', 'Lena', 'Kunze'),
]);
// Zweck sagt nichts, IBAN in der Schreibweise der Bank (mit Leerzeichen).
$match = $module->matchTransaction(
$this->transaction('Restzahlung', 60.0, payerIban: 'DE02 1203 0000 0000 2020 51'),
$candidates,
);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 4: nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest. */
public function test_matches_lastname_plus_exact_open_amount(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0),
$this->participant('b', 'Lena', 'Kunze', amount: 120.0),
]);
$match = $module->matchTransaction($this->transaction('Restbetrag Meier', 80.0), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 5: das Konto läuft auf den Namen -- trägt oft, bei Elternkonten aber nicht. */
public function test_matches_payer_name_only_as_uncertain(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$match = $module->matchTransaction($this->transaction('Ueberweisung', 99.0, payerName: 'Max Meier'), $candidates);
$this->assertNotNull($match);
$this->assertSame(TransactionMatch::CONFIDENCE_UNCERTAIN, $match->confidence);
}
/**
* Zwei Namensgleiche: lieber kein Vorschlag als der falsche. In der Prüfansicht wäre die
* Verwechslung nicht zu erkennen und würde durchgewinkt.
*/
public function test_no_suggestion_when_two_candidates_fit(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Max', 'Meier'),
]);
$this->assertNull($module->matchTransaction($this->transaction('Beitrag Max Meier'), $candidates));
}
public function test_no_suggestion_when_nothing_fits(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$this->assertNull($module->matchTransaction($this->transaction('Rechnung 4711', 60.0, payerName: 'ACME GmbH'), $candidates));
}
public function test_records_the_payer_account(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02 1203 0000 0000 2020 51'),
);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Manche Banken lassen den Namen bei Folgezahlungen leer. Der bereits bekannte Kontoinhaber darf
* dadurch nicht verlorengehen -- ohne ihn ist die IBAN für die Erstattung wertlos.
*/
public function test_a_follow_up_without_payer_name_keeps_the_known_owner(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier', paymentOptions: [
'payer_iban' => 'DE02120300000000202051',
'payer_account_owner' => 'Max Meier',
]);
$participant->refund_data = true;
$module->recordTransaction(
$participant,
$this->transaction('Restzahlung', 60.0, payerName: '', payerIban: 'DE02120300000000202051'),
);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Eine IBAN mit Zahlendreher besteht die Prüfziffer nicht und wird nicht übernommen -- sonst
* ginge die Erstattung später an eine fremde Person.
*/
public function test_ignores_an_invalid_iban(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02120300000000202015'),
);
$this->assertSame([], $participant->payment_options);
$this->assertNotTrue($participant->refund_data);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit;
use App\Providers\BankStatementParseProvider;
use App\Support\BankStatementParseException;
use App\ValueObjects\BankStatementRuleset;
use PHPUnit\Framework\TestCase;
/**
* Der Parser -- zahlartneutral und ohne Framework. Getestet werden die Formatfallen, an denen ein
* Bank-Export scheitert: Zeichensatz, Trennzeichen, Zahlformat, fehlende Spalten.
*/
class BankStatementParseTest extends TestCase
{
private function ruleset(array $overrides = []): BankStatementRuleset
{
$base = [
'delimiter' => ';',
'enclosure' => '',
'charset' => 'Windows-1252',
'has_header' => true,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
'columns' => [
'payment_date' => 'Buchungstag',
'purpose' => 'Verwendungszweck',
'amount' => 'Betrag',
'payer_name' => 'Name Zahlungsbeteiligter',
'payer_iban' => 'IBAN Zahlungsbeteiligter',
],
];
return new BankStatementRuleset(
delimiter: $overrides['delimiter'] ?? $base['delimiter'],
enclosure: $overrides['enclosure'] ?? $base['enclosure'],
charset: $overrides['charset'] ?? $base['charset'],
hasHeader: $overrides['has_header'] ?? $base['has_header'],
dateFormat: $overrides['date_format'] ?? $base['date_format'],
decimalSeparator: $overrides['decimal_separator'] ?? $base['decimal_separator'],
thousandsSeparator: $overrides['thousands_separator'] ?? $base['thousands_separator'],
columns: $overrides['columns'] ?? $base['columns'],
);
}
/** Das Format, das die GLS heute liefert: Semikolon, keine Anführungszeichen, CRLF. */
public function test_reads_the_semicolon_export_without_enclosures(): void
{
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset());
$this->assertCount(1, $transactions);
$this->assertSame('2026-09-09', $transactions[0]->paymentDate->format('Y-m-d'));
$this->assertSame(120.0, $transactions[0]->amount->getAmount());
$this->assertSame('Max Meier', $transactions[0]->payerName);
$this->assertSame('DE02120300000000202051', $transactions[0]->payerIban);
$this->assertSame('Sommerlager - Beitrag Max Meier', $transactions[0]->purpose);
// Kopfzeile ist Zeile 1.
$this->assertSame(2, $transactions[0]->rowNumber);
}
public function test_reads_comma_separated_export_with_enclosures(): void
{
$csv = "\"Buchungstag\",\"Verwendungszweck\",\"Betrag\"\n"
. "\"09.09.2026\",\"Beitrag, erste Rate\",\"85,50\"\n";
$transactions = new BankStatementParseProvider()->parse(
$csv,
$this->ruleset([
'delimiter' => ',',
'enclosure' => '"',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]),
);
$this->assertCount(1, $transactions);
// Das Komma im Zweck darf die Zeile nicht zerreißen.
$this->assertSame('Beitrag, erste Rate', $transactions[0]->purpose);
$this->assertSame(85.5, $transactions[0]->amount->getAmount());
}
public function test_converts_windows_1252_umlauts(): void
{
$csv = mb_convert_encoding(
"Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n",
'Windows-1252',
'UTF-8',
);
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
/**
* Der Schutz gegen Doppel-Kodierung: Liefert die Bank entgegen der Einstellung UTF-8, darf aus
* „Müller" kein „Müller" werden. Die Konvertierung würde technisch gelingen -- der Fehler fiele
* erst auf der Rechnung auf.
*/
public function test_does_not_double_encode_utf8_content(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'charset' => 'Windows-1252',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
public function test_parses_german_thousands_and_negative_amounts(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Grosse Zahlung;1.234,56\n"
. "10.09.2026;Rueckbuchung;-56,00\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
// Der Parser filtert nicht -- ob eine Belastung zählt, entscheidet das Zahlungsmodul.
$this->assertCount(2, $transactions);
$this->assertSame(1234.56, $transactions[0]->amount->getAmount());
$this->assertSame(-56.0, $transactions[1]->amount->getAmount());
$this->assertTrue($transactions[0]->isCredit());
$this->assertFalse($transactions[1]->isCredit());
}
/** Summenzeilen und Fußnoten sind keine Umsätze und dürfen den Import nicht abbrechen. */
public function test_skips_rows_without_date_or_amount(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Beitrag;120,00\n"
. ";Summe;;\n"
. "10.09.2026;Ohne Betrag;\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertCount(1, $transactions);
}
/** Der Normalfall bei falsch eingestelltem Ruleset -- die Meldung muss die Spalte nennen. */
public function test_names_the_missing_column(): void
{
$csv = "Buchungstag;Verwendungszweck\n09.09.2026;Beitrag\n";
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/Betrag/');
new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
}
public function test_rejects_a_ruleset_without_required_mapping(): void
{
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/amount/');
new BankStatementParseProvider()->parse(
"Buchungstag;Verwendungszweck\n",
$this->ruleset(['columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck']]),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\PaymentMethod;
use App\ValueObjects\BankStatementRuleset;
use Tests\TestCase;
/**
* Wie sich der app-weite Standard aus config/bankStatement.php und ein Tenant-Override zueinander
* verhalten -- und dass das Ruleset nicht in den Event-Snapshot rutscht.
*
* Braucht den Framework-Kontext (config()), aber keine Datenbank.
*/
class BankStatementRulesetTest extends TestCase
{
public function test_empty_configuration_falls_back_to_the_app_default(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([]);
$this->assertSame(';', $ruleset->delimiter);
$this->assertSame('', $ruleset->enclosure);
$this->assertSame('Windows-1252', $ruleset->charset);
$this->assertSame('Buchungstag', $ruleset->column('payment_date'));
$this->assertSame([], $ruleset->missingRequiredColumns());
}
/**
* Der Override gilt ganz oder gar nicht: Wer die Spalten seiner Bank einträgt, bekommt nicht
* daneben noch die Spaltennamen der GLS untergeschoben und sucht den Fehler an der falschen Stelle.
*/
public function test_a_tenant_override_replaces_the_column_mapping_entirely(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => [
'delimiter' => ',',
'enclosure' => '"',
'columns' => [
'payment_date' => 'Datum',
'purpose' => 'Zweck',
'amount' => 'Summe',
],
],
]);
$this->assertSame(',', $ruleset->delimiter);
$this->assertSame('"', $ruleset->enclosure);
$this->assertSame('Datum', $ruleset->column('payment_date'));
// Nicht aus dem App-Standard ergänzt:
$this->assertNull($ruleset->column('payer_iban'));
}
/** Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular. */
public function test_accepts_the_override_as_json_string(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => json_encode([
'delimiter' => "\t",
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck', 'amount' => 'Summe'],
]),
]);
$this->assertSame("\t", $ruleset->delimiter);
$this->assertSame('Datum', $ruleset->column('payment_date'));
}
public function test_reports_a_missing_required_column(): void
{
$ruleset = BankStatementRuleset::fromConfiguration([
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck'],
]);
$this->assertSame(['amount'], $ruleset->missingRequiredColumns());
}
/**
* Das Kontoauszug-Format beschreibt die Bank, nicht die Zusage an die Teilnehmenden. Im
* Event-Snapshot eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr
* importieren -- IBAN und Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
*/
public function test_the_ruleset_is_stripped_from_the_event_snapshot(): void
{
$configuration = [
'account_owner' => 'Kasse',
'iban' => 'DE02120300000000202051',
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => ['delimiter' => ','],
];
$snapshot = PaymentMethod::stripTenantScopedOptions(
PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
$configuration,
);
$this->assertArrayNotHasKey(AccountTransferPaymentModule::OPTION_STATEMENT_RULESET, $snapshot);
$this->assertSame('Kasse', $snapshot['account_owner']);
$this->assertSame('DE02120300000000202051', $snapshot['iban']);
}
public function test_the_ruleset_option_is_the_only_tenant_scoped_one(): void
{
$this->assertSame(
[AccountTransferPaymentModule::OPTION_STATEMENT_RULESET],
new AccountTransferPaymentModule()->tenantScopedOptionKeys(),
);
}
}

Some files were not shown because too many files have changed in this diff Show More