-
+
+
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css b/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css
new file mode 100644
index 0000000..b00723c
--- /dev/null
+++ b/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css
@@ -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;
+ }
+}
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue
index 5b6367c..3e36d3b 100644
--- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue
+++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue
@@ -42,7 +42,7 @@ const next = () => {
hasError = true
}
- if (props.formData.badeerlaubnis === '-1') {
+ if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
hasError = true
}
@@ -86,7 +86,7 @@ const next = () => {
Badeerlaubnis:
diff --git a/app/Domains/Event/Views/Signup.vue b/app/Domains/Event/Views/Signup.vue
index a941154..031c4d8 100644
--- a/app/Domains/Event/Views/Signup.vue
+++ b/app/Domains/Event/Views/Signup.vue
@@ -3,6 +3,7 @@
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import SignupForm from './Partials/SignUpForm/SignupForm.vue'
+import ShortSignupForm from './Partials/ShortSignUpForm/ShortSignupForm.vue'
import FullScreenModal from "../../../Views/Components/FullScreenModal.vue";
import AvailableEvents from "./Partials/AvailableEvents.vue";
import {ref} from "vue";
@@ -37,7 +38,7 @@ function close() {
- 📍 {{ props.event.postalCode }} {{ props.event.location }}
+ 📍 {{ props.event.fullAddress }}
- 📍 {{ props.event.postalCode }} {{ props.event.location }}
+ 📍 {{ props.event.fullAddress }}
@@ -98,8 +99,13 @@ function close() {
+
request->accountOwner);
- $iban = Iban::normalize($this->request->accountIban);
+ // Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
+ // Freigabe festgeschrieben. Dann gilt es -- und die Felder aus dem Request werden verworfen.
+ //
+ // Das ist der eigentliche Gewinn der ganzen Übung: Der Teili tippt keine IBAN mehr ab, also
+ // kann er sich weder vertippen noch lässt sich über einen direkten Aufruf dieser Route eine
+ // fremde IBAN unterschieben. Erstattet wird auf das Konto, von dem gezahlt wurde, Punkt.
+ $accountIsKnown = !$this->request->donation && filled($refund->account_iban);
+
+ $owner = $accountIsKnown ? (string) $refund->account_owner : trim($this->request->accountOwner);
+ $iban = $accountIsKnown ? (string) $refund->account_iban : Iban::normalize($this->request->accountIban);
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
@@ -83,14 +91,19 @@ class AcceptRefundCommand
. 'von dem der Beitrag gezahlt wurde.';
}
- if ($owner === '') {
- $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
- }
+ // Nur was der Teili selbst eingibt, muss geprüft werden. Das bekannte Konto hat die
+ // Prüfung schon beim Import bestanden -- dort wird eine ungültige IBAN gar nicht erst
+ // übernommen.
+ if (!$accountIsKnown) {
+ if ($owner === '') {
+ $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
+ }
- if ($iban === '') {
- $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
- } elseif (!Iban::isValid($iban)) {
- $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
+ if ($iban === '') {
+ $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
+ } elseif (!Iban::isValid($iban)) {
+ $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
+ }
}
}
diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
index a0ea0f5..80f9ff8 100644
--- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
@@ -8,8 +8,15 @@ class AcceptRefundRequest
{
public function __construct(
public readonly ?ParticipantRefund $refund,
- public readonly string $accountOwner,
- public readonly string $accountIban,
+ /**
+ * Die Eingaben des Teilis -- leer, wenn es nichts einzugeben gab.
+ *
+ * Das ist der Normalfall bei einer Spende (kein Konto nötig) und bei einem bereits bekannten
+ * Konto: Dort steht die Bankverbindung seit der Freigabe am Vorgang, und der Command nimmt
+ * ausschließlich diese.
+ */
+ public readonly string $accountOwner = '',
+ public readonly string $accountIban = '',
/**
* Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts.
*
diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
index 6820fda..ce535d1 100644
--- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
@@ -2,6 +2,7 @@
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
+use App\Enumerations\RefundAccountSource;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
@@ -30,6 +31,12 @@ class CreateRefundDocumentCommand
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
+ /**
+ * Ihre Fassung für Zahlungsarten ohne Ursprungskonto (Barzahlung): Dort gab es kein Konto, von dem
+ * der Beitrag kam -- erklärt wird stattdessen, dass das angegebene auf den eigenen Namen läuft.
+ */
+ public const string OWN_ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
+
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
@@ -152,6 +159,18 @@ class CreateRefundDocumentCommand
);
}
+ /**
+ * Die Herkunft des Erstattungskontos, abgeleitet aus der Zahlungsart der Anmeldung.
+ *
+ * Der Beleg wird bei jedem Abruf neu gerendert und nicht gespeichert. Änderte jemand nachträglich
+ * die Zahlungsart, zeigte ein Nachdruck die jeweils andere Kontoerklärung. Praktisch passiert das
+ * nicht -- eine eigene Spalte am Vorgang wäre dafür unverhältnismäßig.
+ */
+ private function accountSource(): RefundAccountSource
+ {
+ return $this->participant->refundData()->source;
+ }
+
private function declarationText(): string
{
if ($this->request->donation) {
@@ -164,14 +183,22 @@ class CreateRefundDocumentCommand
// Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
// gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
+ //
+ // Welche der beiden Kontoerklärungen gilt, sagt die Zahlungsart: Wer bar gezahlt hat, kann
+ // nicht bestätigen, dass das Konto dasselbe ist -- es gab keines.
+ $accountSource = $this->accountSource();
+
return $this->pageText(
self::DECLARATION_TEXT,
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.'
) . ' ' . $this->pageText(
- self::ACCOUNT_DECLARATION_TEXT,
- 'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
- . 'gezahlt wurde.'
+ $accountSource->accountDeclarationText(),
+ $accountSource === RefundAccountSource::None
+ ? 'Ich bestätige, dass das angegebene Konto auf meinen Namen läuft oder ich über dieses '
+ . 'Konto verfügungsberechtigt bin.'
+ : 'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
+ . 'gezahlt wurde.'
);
}
diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
index 1cf8d8a..0cfe746 100644
--- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
@@ -6,6 +6,7 @@ use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
+use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
@@ -29,6 +30,8 @@ class ReleaseRefundCommand
private ParticipantRefundRepository $refunds;
+ private ?GetRefundDataResponse $knownRefundData = null;
+
public function __construct(private readonly ReleaseRefundRequest $request)
{
$this->participant = $request->participant;
@@ -63,6 +66,10 @@ class ReleaseRefundCommand
'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(),
+ // Kennt die Zahlungsart das Konto, von dem der Beitrag kam, steht es von Anfang an
+ // fest. Der Teili entscheidet dann nur noch: auszahlen oder spenden.
+ 'account_owner' => $this->knownAccountOwner(),
+ 'account_iban' => $this->knownAccountIban(),
'released_by' => currentUser()?->id,
'released_at' => now(),
]);
@@ -89,6 +96,31 @@ class ReleaseRefundCommand
return $response;
}
+ /**
+ * Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
+ *
+ * Lazy und einmalig, weil der Aufruf über die Event-Relation des Teilnehmers läuft
+ * ({@see EventParticipant::paymentConfiguration()}) und in einem Vorgang mehrfach gebraucht wird.
+ */
+ private function knownRefundData(): GetRefundDataResponse
+ {
+ return $this->knownRefundData ??= $this->participant->refundData();
+ }
+
+ /**
+ * Bei der Sofort-Einreichung und bei der Spende bleibt das Feld leer: Dort setzt der
+ * {@see AcceptRefundCommand} es -- aus der Eingabe der Aktionsleitung bzw. auf `null`.
+ */
+ private function knownAccountOwner(): ?string
+ {
+ return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountOwner);
+ }
+
+ private function knownAccountIban(): ?string
+ {
+ return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountIban);
+ }
+
/**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
*
diff --git a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
index 031e5c9..3f43b4f 100644
--- a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
+++ b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
@@ -3,16 +3,20 @@
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
+use App\Enumerations\RefundAccountSource;
use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
+use App\Support\Iban;
use Inertia\Response;
/**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
*
- * Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert
+ * Liefert ausschließlich Anzeigedaten -- niemals die vollständige Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
+ * Ist das Konto aus dem Zahlungseingang bekannt, geht es deshalb **maskiert** hinaus: genug zum
+ * Wiedererkennen, zu wenig zum Mitschreiben.
*/
class RefundPageController extends CommonController
{
@@ -58,6 +62,26 @@ class RefundPageController extends CommonController
]);
}
- return array_merge($common, ['state' => 'open']);
+ // Woher das Erstattungskonto kommt, entscheidet die Zahlungsart -- und damit, was die Seite
+ // fragt: gar nichts (Konto liegt vor), die Herkunftsfrage (es gab ein Ursprungskonto) oder nur
+ // die Felder (Barzahlung, es gab nie eines).
+ $accountSource = $refund->account_iban !== null
+ ? RefundAccountSource::Known
+ : $participant->refundData()->source;
+
+ return array_merge($common, [
+ 'state' => 'open',
+ 'accountSource' => $accountSource->value,
+ // Der Name des page_texts-Eintrags, nicht der Text: Welche Erklärung gilt, entscheidet der
+ // Server -- sonst könnten Seite und Beleg auseinanderlaufen.
+ 'accountDeclarationText' => $accountSource->accountDeclarationText(),
+ // Ist das Konto bekannt (aus dem Zahlungseingang), braucht der Teili es nicht einzugeben --
+ // er soll es aber wiedererkennen können, um einen Fehler zu melden. Deshalb maskiert:
+ // Die vollständige Bankverbindung verlässt diese Seite weiterhin nicht.
+ 'knownAccount' => $refund->account_iban === null ? null : [
+ 'owner' => $refund->account_owner,
+ 'ibanMasked' => Iban::mask($refund->account_iban),
+ ],
+ ]);
}
}
diff --git a/app/Domains/ParticipantRefund/Views/RefundPage.vue b/app/Domains/ParticipantRefund/Views/RefundPage.vue
index 2c2abbb..bfc129e 100644
--- a/app/Domains/ParticipantRefund/Views/RefundPage.vue
+++ b/app/Domains/ParticipantRefund/Views/RefundPage.vue
@@ -28,8 +28,35 @@ const props = defineProps({
reasonNote: String,
acceptedAt: String,
donation: Boolean,
+ /*
+ * Das Konto aus dem Zahlungseingang, falls bekannt: {owner, ibanMasked}.
+ *
+ * Ist es gesetzt, wird es nicht mehr abgefragt -- es steht bereits am Vorgang und der Server
+ * nimmt ohnehin nur dieses. Die IBAN kommt maskiert an: zum Wiedererkennen genug, zum
+ * Mitschreiben zu wenig.
+ */
+ knownAccount: {type: Object, default: null},
+ /*
+ * Woher das Erstattungskonto kommt -- entschieden von der Zahlungsart, nicht hier:
+ * known -- liegt vor, wird nur bestätigt
+ * origin -- es gab ein Ursprungskonto, wir fragen danach
+ * none -- es gab nie eines (Barzahlung); dann ist die Herkunftsfrage sinnlos
+ */
+ accountSource: {type: String, default: 'origin'},
+ /** Name des page_texts-Eintrags mit der zweiten Erklärung -- der Server wählt ihn. */
+ accountDeclarationText: {type: String, default: 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'},
})
+const hasKnownAccount = computed(() => props.accountSource === 'known')
+
+/**
+ * Ob nach dem Ursprungskonto gefragt wird.
+ *
+ * Nur wenn es eines gab: Wer bar gezahlt hat, könnte die Frage nicht sinnvoll beantworten -- und landete
+ * bei „Nein" in einer Sackgasse, die ihn auffordert, das Konto zu nennen, von dem überwiesen wurde.
+ */
+const asksForOrigin = computed(() => props.accountSource === 'origin')
+
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
@@ -70,6 +97,32 @@ const accountComplete = computed(
const isDonation = computed(() => decision.value === 'donation')
+/**
+ * Wann die Erklärungen des Auszahlungswegs erscheinen.
+ *
+ * Bekanntes Konto: sofort -- es steht ja schon da. Sonst erst, wenn die Kontoangaben vollständig sind;
+ * wo nach dem Ursprungskonto gefragt wird, zusätzlich erst nach einem „Ja".
+ */
+const showDeclarations = computed(() => {
+ if (decision.value !== 'payout') return false
+ if (hasKnownAccount.value) return true
+ if (!accountComplete.value) return false
+
+ return asksForOrigin.value ? sameAccount.value === true : true
+})
+
+/**
+ * Wann nach Kontoinhaber*in und IBAN gefragt wird.
+ *
+ * Überall dort, wo das Konto nicht schon feststeht -- beim Ursprungskonto-Weg allerdings erst, nachdem
+ * die Herkunftsfrage bejaht wurde: Sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
+ */
+const showAccountFields = computed(() => {
+ if (decision.value !== 'payout' || hasKnownAccount.value) return false
+
+ return asksForOrigin.value ? sameAccount.value === true : true
+})
+
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
@@ -95,8 +148,13 @@ function validate() {
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) {
- 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.'
+ // Beim bekannten Konto gibt es nichts einzugeben und damit auch nichts zu prüfen -- nur die
+ // Bestätigung, dass es stimmt.
+ if (!hasKnownAccount.value) {
+ errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
+ errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
+ }
+
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
@@ -111,15 +169,23 @@ async function submit() {
saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
+ // Beim bekannten Konto gehen ebenfalls keine Kontofelder mit: Der Server nimmt ausschließlich,
+ // was am Vorgang steht, alles Mitgeschickte würde er verwerfen.
const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted}
- : {
- donation: false,
- accountOwner: form.accountOwner,
- accountIban: form.accountIban,
- declarationAccepted: form.declarationAccepted,
- accountDeclarationAccepted: form.accountDeclarationAccepted,
- }
+ : hasKnownAccount.value
+ ? {
+ donation: false,
+ declarationAccepted: form.declarationAccepted,
+ accountDeclarationAccepted: form.accountDeclarationAccepted,
+ }
+ : {
+ donation: false,
+ accountOwner: form.accountOwner,
+ accountIban: form.accountIban,
+ declarationAccepted: form.declarationAccepted,
+ accountDeclarationAccepted: form.accountDeclarationAccepted,
+ }
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
@@ -262,39 +328,84 @@ async function submit() {
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
-->
- Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
- gezahlt wurde?
+
+
+ Wir erstatten auf das Konto, von dem dein Beitrag kam
-
- Ja
- Nein
-
+
+ {{ knownAccount.owner }}
+ {{ knownAccount.ibanMasked }}
+
-
- So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
- das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
- einem anderen Konto überwiesen – etwa dem eines Elternteils
- –, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
- an die Aktionsleitung: {{ props.eventEmail }}
+ 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 }}
-
-
- Zurück zur Auswahl
-
-
+
+
+ Auf welches Konto sollen wir erstatten?
+
+
+ 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.
+
+
+
+
+ Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
+ gezahlt wurde?
+
+
+ Ja
+ Nein
+
+
+
+
+ So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
+ das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
+ einem anderen Konto überwiesen – etwa dem eines Elternteils
+ –, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
+ an die Aktionsleitung: {{ props.eventEmail }}
+
+
+
+ Zurück zur Auswahl
+
+
+
+
+
+
+
Kontoinhaber*in
+
-
+
@@ -356,7 +471,6 @@ async function submit() {
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
-
@@ -429,6 +543,31 @@ h3 {
font-weight: bold;
}
+/* Das bekannte Konto -- Anzeige, kein Feld. Bewusst wie ein Beleg gesetzt und nicht wie ein Formular,
+ damit gar nicht erst der Eindruck entsteht, hier ließe sich etwas ändern. */
+.known-account {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 12px 14px;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ background-color: #f9fafb;
+ margin-bottom: 4px;
+}
+
+.known-account__owner {
+ font-weight: bold;
+ color: #374151;
+}
+
+.known-account__iban {
+ font-family: monospace;
+ font-size: 1.05rem;
+ letter-spacing: 0.05em;
+ color: #4b5563;
+}
+
.field {
margin-bottom: 16px;
}
diff --git a/app/Enumerations/RefundAccountSource.php b/app/Enumerations/RefundAccountSource.php
new file mode 100644
index 0000000..e715703
--- /dev/null
+++ b/app/Enumerations/RefundAccountSource.php
@@ -0,0 +1,47 @@
+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
+ */
+ 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 $config
+ * @return array
+ */
+ public function stripTenantScopedOptions(array $config): array
+ {
+ return array_diff_key($config, array_flip($this->tenantScopedOptionKeys()));
+ }
+
/**
* @param array $input
* @return array
@@ -84,6 +118,24 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getParticipantOptions(), $input);
}
+ /**
+ * Die Teilnehmer-Optionen, die im Anmeldeformular tatsächlich abgefragt werden.
+ *
+ * Optionen mit `system => true` fallen heraus: Sie liegen zwar in `payment_options`, entstehen aber
+ * im Programm statt durch eine Eingabe -- das Zahler-Konto etwa trägt der Import aus dem
+ * Kontoauszug nach. Im Schema müssen sie trotzdem stehen, sonst verwirft
+ * {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
+ *
+ * @return array
+ */
+ public function participantInputOptions(): array
+ {
+ return array_values(array_filter(
+ $this->getParticipantOptions(),
+ static fn (array $option): bool => ($option['system'] ?? false) !== true,
+ ));
+ }
+
/**
* @param array $schema
* @return array
@@ -143,6 +195,19 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return new RegistrationSummaryResponse();
}
+ /**
+ * Standard: Es gab ein Ursprungskonto, wir kennen es nur nicht ({@see RefundAccountSource::Origin}).
+ *
+ * Bewusst die strengere Annahme. Der Teili wird dann gefragt, ob es dasselbe Konto ist, von dem der
+ * Beitrag kam -- die Kontrolle, die verhindert, dass sich über eine Erstattung Geld auf ein fremdes
+ * Konto umleiten lässt. Eine Zahlungsart ohne Ursprungskonto (Barzahlung) muss das ausdrücklich
+ * sagen; stillschweigend die Kontrolle fallen zu lassen wäre die falsche Vorgabe.
+ */
+ public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
+ {
+ return new GetRefundDataResponse();
+ }
+
public function doPayment(DoPaymentRequest $request): DoPaymentResponse
{
// TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen).
diff --git a/app/EventPaymentModules/CLAUDE.md b/app/EventPaymentModules/CLAUDE.md
index f8d3e3a..19ae5a2 100644
--- a/app/EventPaymentModules/CLAUDE.md
+++ b/app/EventPaymentModules/CLAUDE.md
@@ -7,15 +7,16 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
- `EventPaymentModule` — **Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
- `createInvoice()`.
+ `createInvoice()`, `getRefundData()`.
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges).
-- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
+- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
+ `GetRefundData*`, `TransactionMatch`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding.
-- `ProvidesGiroCode` — **Fähigkeits-Interface** (siehe unten).
+- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements` — **Fähigkeits-Interfaces** (siehe unten).
## Kernregeln
@@ -32,6 +33,15 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben.
- Optionaler Schlüssel `'hint'` je Option: erklärender Hilfetext, den die Admin-Render-Stellen unter dem Feld anzeigen
(z.B. bei `payment_information`, dass der Text am Anmeldeende + in der Mail erscheint).
+ - Optionaler Schlüssel `'scope' => 'tenant'` (nur `getOptions()`): Die Option gilt für den **ganzen Mandanten** und
+ wird **nicht** pro Event eingefroren. Abgeleitet über `tenantScopedOptionKeys()` /
+ `stripTenantScopedOptions()`, angewandt beim Copy-on-Assign in `SetPaymentMethodsCommand` und ausgefiltert in
+ `ParticipationFees.vue`. Einziger Fall: `statement_ruleset` (s.u.).
+ - Optionaler Schlüssel `'system' => true` (nur `getParticipantOptions()`): Das Feld liegt zwar in
+ `payment_options`, wird aber **nicht im Anmeldeformular abgefragt** — es entsteht im Programm. Gefiltert wird
+ serverseitig in `AbstractEventPaymentModule::participantInputOptions()`, an das `PaymentMethod::
+ participantOptionsFor()` (und damit die Resource/das Frontend) delegiert. Im Schema müssen die Felder trotzdem
+ stehen, sonst verwirft `sanitizeParticipantOptions()` sie als unbekannte Schlüssel.
- `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`.
- **Aktivierungs-Guard:** Eine Tenant-Zahlungsmethode darf nur `active` werden, wenn alle `required`-Optionen befüllt
@@ -55,6 +65,32 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.
- `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul.
+- **`getRefundData()` — „auf welches Konto wäre zu erstatten, und woher kommt es?"** Steht im **Kern-Interface**, weil
+ jede Zahlungsart eine Antwort darauf hat; sie fällt nur unterschiedlich aus. Geantwortet wird mit
+ `App\Enumerations\RefundAccountSource` (reines Code-Enum, nirgends gespeichert):
+ - `Known` — das Konto liegt vor. Nur die Überweisung liefert das, aus `payment_options`
+ (`payer_iban`/`payer_account_owner`, vom Kontoauszug-Import hinterlegt) und **nur bei gültiger Prüfziffer**; eine
+ ungültige IBAN würde ungeprüft übernommen. `event_participants.refund_data` ist demgegenüber nur ein
+ **abgeleitetes Kennzeichen** für Listen und Abfragen, nie die Quelle — zwei Quellen für dieselbe Wahrheit driften
+ auseinander.
+ - `Origin` — es gab ein Ursprungskonto, wir kennen es nicht. **Vorgabe der Basisklasse**, bewusst die strengere
+ Annahme: Der Teili wird gefragt, ob es dasselbe Konto ist, und bestätigt die Herkunft. Das ist die Kontrolle
+ gegen das Umleiten einer Erstattung auf ein fremdes Konto; sie stillschweigend fallen zu lassen wäre die falsche
+ Vorgabe für ein künftiges Modul.
+ - `None` — es gab **nie** eines (`UndefinedPaymentModule`, Barzahlung). Herkunftsfrage und Herkunfts-Erklärung
+ wären sinnlos bzw. unwahr; an ihre Stelle tritt `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN` („läuft auf meinen
+ Namen"). Welcher `page_texts`-Eintrag gilt, sagt `RefundAccountSource::accountDeclarationText()` — eine Quelle
+ für Seite **und** Beleg.
+ Aufgelöst wird überall über `EventParticipant::refundData()`; verwertet in `ReleaseRefundCommand` (schreibt ein
+ bekanntes Konto direkt an den Vorgang, der aber `pending` bleibt — der Teili entscheidet noch über Auszahlung oder
+ Spende), in `AcceptRefundCommand` (ein gesetztes Konto lässt sich **nicht** aus dem Request überschreiben), im
+ `RefundPageController` und im Erstattungsbeleg. Auf der Token-Seite und in der Freigabe-Mail geht eine bekannte IBAN
+ nur maskiert hinaus (`Iban::mask()`).
+- **Keine Barauszahlung.** Auch wer bar gezahlt hat, bekommt überwiesen. Rechtlich spricht nichts dagegen — das GwG
+ gilt für den Verband nicht (§ 2 Abs. 1 GwG; kein Güterhändler nach § 1 Abs. 9), und eine Regel „bar rein, bar raus"
+ existiert nicht. Die Überweisung ist zudem besser belegt: Der Kontoauszug beweist die Zahlung, während eine
+ Barauszahlung an einer Unterschrift hinge und die Barkasse nach § 146 AO kassensturzfähig zu halten wäre. Wer doch
+ bar auszahlt, bucht das über die normale Auslagenerfassung.
## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation)
@@ -77,6 +113,31 @@ Beispiel GiroCode (nur Überweisung):
- Künftige Verfahren würden analog eigene Fähigkeiten mitbringen (z.B. `ProvidesRedirect` für PayPal,
`ProvidesMandate` für SEPA-Lastschrift) — **erst modellieren, wenn tatsächlich gebraucht.**
+### Kontoauszug-Import (zwei Interfaces, bewusst getrennt)
+
+- `ProvidesStatementRuleset::statementRuleset(array $configuration): BankStatementRuleset` — „dieses Modul pflegt das
+ CSV-Format der Bank". Implementiert **nur** von `AccountTransferPaymentModule`. Das Format ist eine Eigenschaft der
+ **Bank**, nicht der Zahlungsart: eine Bank, ein Export, ein Ruleset. Das kommende Lastschrift-Modul liest denselben
+ Auszug und implementiert dieses Interface **nicht** — sonst wäre dasselbe Format zweimal zu pflegen und nach dem
+ nächsten Bankwechsel eine der beiden Stellen vergessen.
+- `ReadsBankStatements` — „dieses Modul kann Umsätze verwerten": `isRelevantTransaction()` (Überweisung: Gutschriften;
+ Lastschrift später: Belastungen und Rücklastschriften), `matchTransaction()` (Überweisung: Verwendungszweck, Namen,
+ bekannte Zahler-IBAN, Betrag; Lastschrift später: Mandatsreferenz), `recordTransaction()` (Überweisung: Zahler-Konto
+ in `payment_options` + `refund_data`). Nur diese drei Entscheidungen sind zahlartspezifisch.
+- **Ablage des Rulesets:** App-Standard in `config/bankStatement.php` (GLS Gemeinschaftsbank), Tenant-Override in der
+ Modul-Option `statement_ruleset` (`type: 'bank-ruleset'`, `scope: 'tenant'`). Der Override gilt **ganz oder gar
+ nicht** — kein feldweiser Merge, sonst bekäme man beim Umstellen des Trennzeichens weiterhin die Spaltennamen der
+ GLS untergeschoben.
+- **Kandidaten schließen Abgemeldete ein** (`EventParticipantRepository::getForPaymentMatching()`): Wer den Beitrag
+ überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Die Zahlung wird erfasst — erst dann gibt
+ es etwas zu erstatten. Die Prüfansicht weist die Abmeldung aus (`isSignedOff`/`signedOffAt`).
+- **Modul-Schicht bleibt DB-frei:** Die Konfiguration wird hereingereicht, die Kandidaten für `matchTransaction()`
+ ebenfalls. Geholt wird beides vom `PaymentMethodRepository` bzw. `EventParticipantRepository`, verdrahtet in den
+ Actions `ParseBankStatement` / `BookBankStatementPayments` (Domain `Event`). `doPayment()` ist **nicht** beteiligt:
+ das stößt eine Zahlung an, hier wird eine bereits erfolgte nachgetragen.
+- Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
+ dieser Schicht — sie sind zahlartneutral.
+
## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -118,6 +179,11 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
-`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`,
-`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5):
-`docker exec mareike-mareike-app-1 php artisan test`.
+`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
+`tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
+`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
+`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
+
+Ausführung im Container (PHP 8.5). `php artisan test` läuft im 128-MB-Limit auf `config/postCode.php` in einen
+Speicherfehler, deshalb direkt über PHPUnit mit angehobenem Limit:
+`docker exec mareike-mareike-app-1 php -d memory_limit=1G vendor/bin/phpunit`
diff --git a/app/EventPaymentModules/DTO/GetRefundDataRequest.php b/app/EventPaymentModules/DTO/GetRefundDataRequest.php
new file mode 100644
index 0000000..5a18072
--- /dev/null
+++ b/app/EventPaymentModules/DTO/GetRefundDataRequest.php
@@ -0,0 +1,23 @@
+ $configuration aufgelöste Konfiguration des Zahlungsmoduls
+ */
+ public function __construct(
+ public readonly EventParticipant $participant,
+ public readonly array $configuration = [],
+ ) {
+ }
+}
diff --git a/app/EventPaymentModules/DTO/GetRefundDataResponse.php b/app/EventPaymentModules/DTO/GetRefundDataResponse.php
new file mode 100644
index 0000000..208d813
--- /dev/null
+++ b/app/EventPaymentModules/DTO/GetRefundDataResponse.php
@@ -0,0 +1,33 @@
+source === RefundAccountSource::Known;
+ }
+}
diff --git a/app/EventPaymentModules/DTO/TransactionMatch.php b/app/EventPaymentModules/DTO/TransactionMatch.php
new file mode 100644
index 0000000..c1e975b
--- /dev/null
+++ b/app/EventPaymentModules/DTO/TransactionMatch.php
@@ -0,0 +1,25 @@
+ 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false],
['name' => 'summary_confirmation_text', 'label' => 'Bestätigung durch Teilnehmende (Zusammenfassung, {amount} als Platzhalter)', 'type' => 'string', 'required' => false],
['name' => 'icon', 'label' => 'Symbol', 'type' => 'icon', 'required' => false],
+ [
+ 'name' => self::OPTION_STATEMENT_RULESET,
+ 'label' => 'Format des Kontoauszugs (CSV)',
+ 'type' => 'bank-ruleset',
+ 'required' => false,
+ 'scope' => 'tenant',
+ 'hint' => 'Nur ausfüllen, wenn eure Bank ein anderes Format liefert als der App-Standard. '
+ . 'Die Einstellung gilt für alle Aktionen -- auch für bereits laufende.',
+ ],
];
}
+ /**
+ * Beim Zahlungsimport nachgetragen, nicht beim Anmelden abgefragt: `system => true` blendet die
+ * Felder im Anmeldeformular aus. Im Schema müssen sie trotzdem stehen, sonst verwirft
+ * {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
+ */
+ public function getParticipantOptions(): array
+ {
+ return [
+ [
+ 'name' => self::OPTION_PAYER_ACCOUNT_OWNER,
+ 'label' => 'Kontoinhaber*in der Zahlung',
+ 'type' => 'string',
+ 'required' => false,
+ 'system' => true,
+ ],
+ [
+ 'name' => self::OPTION_PAYER_IBAN,
+ 'label' => 'IBAN der Zahlung',
+ 'type' => 'string',
+ 'required' => false,
+ 'system' => true,
+ ],
+ ];
+ }
+
+ public function statementRuleset(array $configuration): BankStatementRuleset
+ {
+ $override = $configuration[self::OPTION_STATEMENT_RULESET] ?? null;
+
+ // Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular.
+ if (is_string($override)) {
+ $decoded = json_decode($override, true);
+ $override = is_array($decoded) ? $decoded : null;
+ }
+
+ return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null);
+ }
+
+ /**
+ * Erstattet wird auf das Konto, von dem der Beitrag kam -- und genau das steht seit dem
+ * Zahlungsimport in den Teilnehmer-Optionen.
+ *
+ * Gelesen wird ausschließlich aus `payment_options`, nicht aus dem Kennzeichen `refund_data`:
+ * Das ist ein abgeleitetes Merkmal für Listen und Abfragen. Zwei Quellen für dieselbe Wahrheit
+ * driften früher oder später auseinander, und die falsche gewänne dann eine Auszahlung.
+ *
+ * Eine IBAN, die die Prüfziffer nicht besteht, wird nicht gemeldet: Sie würde ungeprüft
+ * übernommen und das Geld ginge im Zweifel an eine fremde Person. Lieber wie bisher nachfragen.
+ */
+ public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
+ {
+ $response = new GetRefundDataResponse();
+
+ $options = $request->participant->payment_options ?? [];
+ $iban = Iban::normalize((string) ($options[self::OPTION_PAYER_IBAN] ?? ''));
+ $owner = trim((string) ($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? ''));
+
+ if ($iban === '' || $owner === '' || !Iban::isValid($iban)) {
+ return $response;
+ }
+
+ $response->source = RefundAccountSource::Known;
+ $response->accountOwner = $owner;
+ $response->accountIban = $iban;
+
+ $paidOn = $request->participant->last_payment_date?->format('d.m.Y');
+ $response->sourceNote = $paidOn === null
+ ? 'Zahlungseingang'
+ : 'Zahlungseingang vom ' . $paidOn;
+
+ return $response;
+ }
+
+ /** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */
+ public function isRelevantTransaction(BankTransaction $transaction): bool
+ {
+ return $transaction->isCredit();
+ }
+
+ /**
+ * Erkennt die Anmeldung zu einem Zahlungseingang.
+ *
+ * Die Regeln greifen in fester Reihenfolge und liefern nur dann etwas, wenn **genau eine**
+ * Anmeldung passt. Bei zwei Treffern -- zwei Geschwister mit gleichem Nachnamen, zwei
+ * Namensgleiche im selben Lager -- gibt es bewusst keinen Vorschlag: die falsche Zuordnung wäre
+ * in der Prüfansicht nicht zu erkennen und würde durchgewinkt.
+ *
+ * @param Collection $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 $candidates
+ * @param callable(EventParticipant): bool $matches
+ */
+ private function onlyOne(Collection $candidates, callable $matches): ?EventParticipant
+ {
+ $hits = $candidates->filter($matches);
+
+ return $hits->count() === 1 ? $hits->first() : null;
+ }
+
+ /** Noch offener Beitrag. `clone`, weil Amount seine Rechenoperationen auf sich selbst ausführt. */
+ private function amountLeft(EventParticipant $participant): Amount
+ {
+ $amountLeft = clone $participant->amount;
+
+ if ($participant->amount_paid !== null) {
+ $amountLeft->subtractAmount($participant->amount_paid);
+ }
+
+ return $amountLeft;
+ }
+
+ /** Kleinschreibung, Umlaute aufgelöst, alles außer [a-z0-9] raus. */
+ private function normalize(string $value): string
+ {
+ $value = mb_strtolower(trim($value));
+
+ $value = strtr($value, [
+ 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
+ 'á' => 'a', 'à' => 'a', 'â' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e',
+ 'í' => 'i', 'ì' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ú' => 'u', 'ù' => 'u',
+ 'ç' => 'c', 'ñ' => 'n',
+ ]);
+
+ return preg_replace('/[^a-z0-9]/', '', $value) ?? '';
+ }
+
public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{
$participant = $request->participant;
$config = $request->configuration;
- $amountLeft = clone $participant->amount;
- if ($participant->amount_paid !== null) {
- $amountLeft->subtractAmount($participant->amount_paid);
- }
+ $amountLeft = $this->amountLeft($participant);
$hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +351,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string
{
- $amountLeft = clone $participant->amount;
- if ($participant->amount_paid !== null) {
- $amountLeft->subtractAmount($participant->amount_paid);
- }
+ $amountLeft = $this->amountLeft($participant);
if ($amountLeft->getAmount() <= 0) {
return null;
diff --git a/app/EventPaymentModules/Modules/UndefinedPaymentModule.php b/app/EventPaymentModules/Modules/UndefinedPaymentModule.php
index d9c4875..a0e3b69 100644
--- a/app/EventPaymentModules/Modules/UndefinedPaymentModule.php
+++ b/app/EventPaymentModules/Modules/UndefinedPaymentModule.php
@@ -2,8 +2,11 @@
namespace App\EventPaymentModules\Modules;
+use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
+use App\EventPaymentModules\DTO\GetRefundDataRequest;
+use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -57,6 +60,25 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
return $response;
}
+ /**
+ * Bar gezahlt heißt: Es gab nie ein Konto, von dem der Beitrag kam.
+ *
+ * Damit greift die sonst geltende Kontrolle „zurück nur auf das Ursprungskonto" nicht -- die Frage
+ * danach wäre für den Teili sinnlos und die Erklärung, es sei dasselbe Konto, schlicht unwahr. An
+ * ihre Stelle tritt die Erklärung, dass das angegebene Konto auf seinen Namen läuft.
+ *
+ * Zurückgezahlt wird trotzdem per Überweisung: Der Kontoauszug belegt die Zahlung, während eine
+ * Barauszahlung an einer Unterschrift hinge und die Barkasse berührte. Rechtlich spricht nichts
+ * dagegen -- eine Regel „bar rein, bar raus" gibt es nicht.
+ */
+ public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
+ {
+ $response = new GetRefundDataResponse();
+ $response->source = RefundAccountSource::None;
+
+ return $response;
+ }
+
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{
return sprintf(
diff --git a/app/EventPaymentModules/ProvidesStatementRuleset.php b/app/EventPaymentModules/ProvidesStatementRuleset.php
new file mode 100644
index 0000000..e26bfab
--- /dev/null
+++ b/app/EventPaymentModules/ProvidesStatementRuleset.php
@@ -0,0 +1,25 @@
+ $configuration Tenant-Konfiguration dieses Moduls
+ */
+ public function statementRuleset(array $configuration): BankStatementRuleset;
+}
diff --git a/app/EventPaymentModules/ReadsBankStatements.php b/app/EventPaymentModules/ReadsBankStatements.php
new file mode 100644
index 0000000..ce92999
--- /dev/null
+++ b/app/EventPaymentModules/ReadsBankStatements.php
@@ -0,0 +1,42 @@
+ $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;
+}
diff --git a/app/Mail/ParticipantRefundMails/RefundReleasedMail.php b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php
index 605b694..1467cb8 100644
--- a/app/Mail/ParticipantRefundMails/RefundReleasedMail.php
+++ b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php
@@ -2,8 +2,10 @@
namespace App\Mail\ParticipantRefundMails;
+use App\Enumerations\RefundAccountSource;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
+use App\Support\Iban;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
@@ -45,6 +47,17 @@ class RefundReleasedMail extends Mailable
'reasonNote' => $this->refund->reason_note,
// Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
'link' => url('/rueckerstattung/' . $this->refund->token),
+ // Steht das Konto schon fest (aus dem Zahlungseingang), fragt die Mail nicht nach der
+ // Bankverbindung, sondern nur noch nach der Entscheidung auszahlen/spenden.
+ //
+ // Maskiert, aus demselben Grund wie auf der Seite: Diese Mail liegt in einem Postfach.
+ 'knownAccountOwner' => $this->refund->account_owner,
+ 'knownAccountIban' => $this->refund->account_iban === null
+ ? null
+ : Iban::mask($this->refund->account_iban),
+ // Ohne Ursprungskonto (Barzahlung) wäre der Hinweis „nur auf das Konto, von dem gezahlt
+ // wurde" falsch -- dort ist stattdessen ein Konto auf den eigenen Namen gefragt.
+ 'hasOriginAccount' => $this->participant->refundData()->source !== RefundAccountSource::None,
],
);
}
diff --git a/app/Models/Event.php b/app/Models/Event.php
index 0bd2b6f..68e6a4b 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $cost_unit_id
* @property string $name
* @property string $location
+ * @property string|null $street
+ * @property string|null $house_number
* @property string $postal_code
* @property string $email
* @property DateTime $start_date
@@ -45,6 +47,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property float $support_flat
* @property int $alcoholics_age
* @property boolean $archived
+ * @property boolean $short_registration
+ * @property boolean $swimming_permission_required
* @property string|null $invoice_key
*/
class Event extends InstancedModel
@@ -57,6 +61,8 @@ class Event extends InstancedModel
'name',
'identifier',
'location',
+ 'street',
+ 'house_number',
'postal_code',
'email',
'start_date',
@@ -91,6 +97,9 @@ class Event extends InstancedModel
'participation_options',
'addons',
+ 'short_registration',
+ 'swimming_permission_required',
+
'invoice_key',
];
@@ -121,8 +130,23 @@ class Event extends InstancedModel
'participation_options' => 'array',
'addons' => 'array',
+
+ 'short_registration' => 'boolean',
+ 'swimming_permission_required' => 'boolean',
];
+ /**
+ * Anschrift des Veranstaltungsorts in einer Zeile. Straße und Hausnummer sind optional -- fehlen sie,
+ * bleibt es bei der bisherigen Ausgabe "PLZ Ort".
+ */
+ public function getFullAddress(): string
+ {
+ $city = trim($this->postal_code . ' ' . $this->location);
+ $street = trim($this->street . ' ' . $this->house_number);
+
+ return '' === $street ? $city : $street . ', ' . $city;
+ }
+
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant', 'slug');
@@ -205,4 +229,18 @@ class Event extends InstancedModel
return $this->hasMany(EventParticipant::class);
}
+ /**
+ * Slug der ersten konfigurierten Teilnahmegruppe. Die Kurzanmeldung fragt die Gruppe nicht ab und ordnet
+ * jede Anmeldung dieser Gruppe zu. Null, wenn für die Veranstaltung noch keine Gebühr hinterlegt ist --
+ * dann ist eine Kurzanmeldung nicht möglich, weil `participation_type` am Teilnehmer Pflicht ist.
+ */
+ public function firstParticipationTypeSlug() : ?string {
+ return collect([
+ $this->participationFee1,
+ $this->participationFee2,
+ $this->participationFee3,
+ $this->participationFee4,
+ ])->first(fn ($participationFee) => $participationFee !== null)?->type;
+ }
+
}
diff --git a/app/Models/EventParticipant.php b/app/Models/EventParticipant.php
index c78fe54..46a79b1 100644
--- a/app/Models/EventParticipant.php
+++ b/app/Models/EventParticipant.php
@@ -8,6 +8,8 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
+use App\EventPaymentModules\DTO\GetRefundDataRequest;
+use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -74,6 +76,8 @@ class EventParticipant extends InstancedModel
'payment_purpose',
'payment_method',
'payment_options',
+ 'refund_data',
+ 'last_payment_date',
'efz_status',
'unregistered_at',
];
@@ -94,6 +98,8 @@ class EventParticipant extends InstancedModel
'amount' => AmountCast::class,
'amount_paid' => AmountCast::class,
'payment_options' => 'array',
+ 'refund_data' => 'boolean',
+ 'last_payment_date' => 'date',
'invoice_sequence' => 'integer',
'sibling_reduction' => 'boolean',
@@ -234,4 +240,21 @@ class EventParticipant extends InstancedModel
return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration(), $context));
}
+
+ /**
+ * Auf welches Konto wäre zu erstatten -- und woher kommt es?
+ *
+ * Einmal hier statt viermal verstreut: Die Freigabe, die Teilnehmerliste, die öffentliche
+ * Erstattungsseite und der Beleg stellen dieselbe Frage und müssen dieselbe Antwort bekommen.
+ * Fehlt die Zahlungsart, gilt die strengere Vorgabe des Moduls-Standards (Ursprungskonto).
+ */
+ public function refundData(): GetRefundDataResponse
+ {
+ $module = $this->paymentModule();
+ if ($module === null) {
+ return new GetRefundDataResponse();
+ }
+
+ return $module->getRefundData(new GetRefundDataRequest($this, $this->paymentConfiguration()));
+ }
}
diff --git a/app/Models/PaymentMethod.php b/app/Models/PaymentMethod.php
index d2d7023..2ff43f3 100644
--- a/app/Models/PaymentMethod.php
+++ b/app/Models/PaymentMethod.php
@@ -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
*/
public static function participantOptionsFor(string $slug): array
{
- return EventPaymentModuleRegistry::forSlug($slug)?->getParticipantOptions() ?? [];
+ return EventPaymentModuleRegistry::forSlug($slug)?->participantInputOptions() ?? [];
}
/**
@@ -88,6 +90,20 @@ class PaymentMethod extends CommonModel
return EventPaymentModuleRegistry::forSlug($slug)?->sanitizeConfiguration($config) ?? [];
}
+ /**
+ * Entfernt die tenant-weiten Optionen eines Slugs aus einer Konfiguration.
+ *
+ * Aufgerufen beim Copy-on-Assign an eine Veranstaltung: Was den Mandanten als Ganzes betrifft
+ * (z.B. das Kontoauszug-Format), darf nicht pro Aktion einfrieren.
+ *
+ * @param array $config
+ * @return array
+ */
+ public static function stripTenantScopedOptions(string $slug, array $config): array
+ {
+ return EventPaymentModuleRegistry::forSlug($slug)?->stripTenantScopedOptions($config) ?? $config;
+ }
+
/**
* Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind.
* Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder.
diff --git a/app/Providers/BankStatementParseProvider.php b/app/Providers/BankStatementParseProvider.php
new file mode 100644
index 0000000..66c3d99
--- /dev/null
+++ b/app/Providers/BankStatementParseProvider.php
@@ -0,0 +1,212 @@
+
+ *
+ * @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 */
+ 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
+ *
+ * @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
+ */
+ 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 $fields
+ * @param array $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');
+ }
+}
diff --git a/app/Repositories/EventParticipantRepository.php b/app/Repositories/EventParticipantRepository.php
index 9e0e118..9b71078 100644
--- a/app/Repositories/EventParticipantRepository.php
+++ b/app/Repositories/EventParticipantRepository.php
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Models\Event;
use App\Models\EventParticipant;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
class EventParticipantRepository {
public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array {
@@ -39,6 +40,39 @@ class EventParticipantRepository {
return $participant;
}
+ /**
+ * Anmeldungen, gegen die ein Zahlungseingang zugeordnet werden kann.
+ *
+ * Abgemeldete sind ausdrücklich dabei: Wer den Beitrag überwiesen und sich danach abgemeldet hat,
+ * steht trotzdem im Kontoauszug. Die Zahlung muss erfasst werden -- erst dann gibt es überhaupt
+ * etwas zu erstatten. Ließe man sie hier weg, bliebe der Eingang unzuordenbar und das Geld läge
+ * unbemerkt auf dem Konto.
+ *
+ * `event.paymentMethods` wird mitgeladen, weil die Zuordnung je Anmeldung das Zahlungsmodul
+ * auflöst und das sonst pro Zeile eine Abfrage wäre.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function getForPaymentMatching(Event $event) : Collection {
+ return $event->participants()
+ ->with('event.paymentMethods')
+ ->orderBy('lastname')
+ ->orderBy('firstname')
+ ->get();
+ }
+
+ /**
+ * Eine Anmeldung dieser Veranstaltung -- der Identifier kommt beim Zahlungsimport aus dem Browser
+ * und wird deshalb hier gegen die Aktion geprüft. Der Tenant-Filter kommt vom SiteScope.
+ *
+ * Abgemeldete eingeschlossen, aus demselben Grund wie in {@see getForPaymentMatching()}.
+ */
+ public function findInEventByIdentifier(Event $event, string $identifier) : ?EventParticipant {
+ return EventParticipant::where('identifier', $identifier)
+ ->where('event_id', $event->id)
+ ->first();
+ }
+
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
diff --git a/app/Repositories/PaymentMethodRepository.php b/app/Repositories/PaymentMethodRepository.php
new file mode 100644
index 0000000..f6e1762
--- /dev/null
+++ b/app/Repositories/PaymentMethodRepository.php
@@ -0,0 +1,27 @@
+
+ */
+ public function tenantConfiguration(string $slug): array
+ {
+ return (array) (AvailablePaymentMethod::where('slug', $slug)->first()?->configuration ?? []);
+ }
+}
diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php
index 1258e2a..83c6b24 100644
--- a/app/Resources/EventParticipantResource.php
+++ b/app/Resources/EventParticipantResource.php
@@ -9,6 +9,7 @@ use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Repositories\ParticipantRefundRepository;
+use App\Support\Iban;
use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -51,12 +52,15 @@ class EventParticipantResource extends JsonResource
'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
- 'swimmingPermission' => $this->resource->swimmingPermission()->first()->short,
- 'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name,
+ // Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
+ // lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
+ 'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
+ 'extendedFirstAid' => $this->resource->firstAidPermission()->first()?->name ?? 'Unbekannt',
'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt',
'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null,
'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport],
- 'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()->name,
+ 'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()?->name
+ ?? $this->resource->participation_type,
'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(),
@@ -79,13 +83,14 @@ class EventParticipantResource extends JsonResource
// Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
// der letzte abgebrochen wurde.
'refund' => $this->refund($request),
+ 'refundData' => $this->refundData(),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
'state' => config('postCode.map.' . $this->resource->postcode),
'localGroupState' => null !== $this->resource->localGroup()->first()?->postcode ? config('postCode.map.' . $this->resource->postcode) : '--',
'birthday' => $this->resource->birthday->format('d.m.Y'),
- 'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()->name,
+ 'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()?->name ?? 'Unbekannt',
'paymentMethod' => $this->resource->payment_method !== null
? AvailablePaymentMethod::withoutGlobalScopes()->where('tenant', $this->resource->tenant)->where('slug', $this->resource->payment_method)->first()?->name ?? $this->resource->payment_method
: null,
@@ -94,14 +99,17 @@ class EventParticipantResource extends JsonResource
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red',
+ default => 'bg-yellow',
},
'efzStatusReadable' => match($this->resource->efz_status) {
EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft',
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich',
+ default => 'Nicht geprüft',
},
'eventName' => $this->resource->event()->first()->name,
+ 'eventAddress' => $event->getFullAddress(),
'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'),
'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'),
'participationOptions' => $this->resource->selectedOptions()->get()->map(
@@ -124,4 +132,28 @@ class EventParticipantResource extends JsonResource
return $refund?->toResource()->toArray($request);
}
+
+ /**
+ * Das Konto, auf das erstattet würde -- sofern die Zahlungsart es kennt.
+ *
+ * Unmaskiert: Diese Resource speist die Teilnehmerliste der Aktionsleitung, die IBANs ohnehin
+ * sehen und eingeben darf. Auf der öffentlichen Erstattungsseite geht dieselbe Angabe maskiert
+ * hinaus, dort besorgt das der RefundPageController.
+ *
+ * @return array
+ */
+ 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,
+ ];
+ }
}
diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php
index 6077f88..c4858ff 100644
--- a/app/Resources/EventResource.php
+++ b/app/Resources/EventResource.php
@@ -30,7 +30,12 @@ class EventResource extends JsonResource{
'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup',
'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier,
'location' => $this->event->location,
+ 'street' => $this->event->street,
+ 'houseNumber' => $this->event->house_number,
'postalCode' => $this->event->postal_code,
+ // Fertig formatierte Anschrift für alle Anzeigen -- die Einzelfelder brauchen nur die
+ // Bearbeitungsformulare.
+ 'fullAddress' => $this->event->getFullAddress(),
'email' => $this->event->email,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
@@ -76,6 +81,10 @@ class EventResource extends JsonResource{
$returnArray['nameShort'] = substr($returnArray['nameShort'], 8, 13) . '...';
}
+ // Steuert im Frontend, welcher Anmelde-Wizard gerendert wird, und ob die Badeerlaubnis abgefragt wird.
+ $returnArray['shortRegistration'] = (bool)$this->event->short_registration;
+ $returnArray['swimmingPermissionRequired'] = (bool)$this->event->swimming_permission_required;
+
$returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true;
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
$returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY;
diff --git a/app/Scopes/CommonController.php b/app/Scopes/CommonController.php
index b5268fd..58c87a6 100644
--- a/app/Scopes/CommonController.php
+++ b/app/Scopes/CommonController.php
@@ -13,6 +13,7 @@ use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
+use App\Repositories\PaymentMethodRepository;
use App\Repositories\UserRepository;
abstract class CommonController {
@@ -27,6 +28,7 @@ abstract class CommonController {
protected EventParticipantRepository $eventParticipants;
protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates;
+ protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
@@ -40,6 +42,7 @@ abstract class CommonController {
$this->eventParticipants = new EventParticipantRepository();
$this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository();
+ $this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
}
diff --git a/app/Support/BankStatementParseException.php b/app/Support/BankStatementParseException.php
new file mode 100644
index 0000000..2a93cd8
--- /dev/null
+++ b/app/Support/BankStatementParseException.php
@@ -0,0 +1,17 @@
+ implode('', $chunk),
+ array_chunk(mb_str_split($value), 4),
+ ));
+ }
+
/**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 … Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
diff --git a/app/ValueObjects/BankStatementRuleset.php b/app/ValueObjects/BankStatementRuleset.php
new file mode 100644
index 0000000..6993076
--- /dev/null
+++ b/app/ValueObjects/BankStatementRuleset.php
@@ -0,0 +1,139 @@
+ 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 $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|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 $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
+ */
+ public function missingRequiredColumns(): array
+ {
+ return array_values(array_filter(
+ self::REQUIRED_COLUMNS,
+ fn (string $field): bool => $this->column($field) === null,
+ ));
+ }
+
+ /** @return array */
+ 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,
+ ];
+ }
+}
diff --git a/app/ValueObjects/BankTransaction.php b/app/ValueObjects/BankTransaction.php
new file mode 100644
index 0000000..6ddb675
--- /dev/null
+++ b/app/ValueObjects/BankTransaction.php
@@ -0,0 +1,49 @@
+amount->getAmount() > 0;
+ }
+
+ /** @return array */
+ 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,
+ ];
+ }
+}
diff --git a/app/Views/Components/BankRulesetEditor.vue b/app/Views/Components/BankRulesetEditor.vue
new file mode 100644
index 0000000..d010853
--- /dev/null
+++ b/app/Views/Components/BankRulesetEditor.vue
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+ App-Standard verwenden
+
+
+
+ Eigenes Format für unsere Bank
+
+
+
+ Es gilt der app-weite Standard. Bearbeitet ihr das Format, gilt ausschließlich eure
+ Einstellung — der Standard wirkt dann nicht mehr nach.
+
+
+
+
+
Spalten der Datei
+
+ Die Überschrift der jeweiligen Spalte, genau so wie sie in der ersten Zeile des Exports steht.
+
+
+
+
+
+ Ohne {{ missingRequired.join(', ') }} lässt sich kein Kontoauszug einlesen.
+
+
+
+
+
diff --git a/app/Views/Components/RichSelectBox.vue b/app/Views/Components/RichSelectBox.vue
index c168065..477d452 100644
--- a/app/Views/Components/RichSelectBox.vue
+++ b/app/Views/Components/RichSelectBox.vue
@@ -1,57 +1,229 @@