Automatischer Zahlungsparser

This commit is contained in:
2026-09-10 09:50:14 +02:00
parent 025035190d
commit 6301c342e4
24 changed files with 1692 additions and 83 deletions
@@ -86,6 +86,16 @@ const retainedAmount = computed(() => {
const hasRetention = computed(() => retainedAmount.value > 0);
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Ist es da, wird es weder erfragt noch bearbeitet: Erstattet wird auf das Konto, von dem der Beitrag
* kam, und genau das steht hier. `null`, solange nichts bekannt ist (Barzahlung, Altbestand).
*/
const knownRefundAccount = computed(
() => showParticipant.value?.refundData?.available ? showParticipant.value.refundData : null
);
const retainedAmountReadable = computed(
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro'
);
@@ -121,7 +131,8 @@ const refundFormComplete = computed(() => {
}
}
if (refundForm.captureMode === 'management') {
// Beim bekannten Konto gibt es nichts einzugeben -- es steht fest.
if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== '';
}
@@ -429,7 +440,7 @@ function validateRefund() {
// Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
if (refundForm.captureMode === 'management') {
if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
}
@@ -456,9 +467,14 @@ async function execRefund() {
amount: refundForm.amount,
reason: refundForm.reason,
reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. Beim
// Sofort-Einreichen mit bekanntem Konto geht dieses mit; eingegeben wurde nichts.
accountOwner: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountOwner ?? refundForm.accountOwner)
: '',
accountIban: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountIban ?? refundForm.accountIban)
: '',
// Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
@@ -805,14 +821,36 @@ function mailToGroup(groupKey) {
Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn:
die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem.
-->
<!--
Ist das Konto aus dem Zahlungseingang bekannt, wird es weder erfragt noch bearbeitet: Es
steht fest, dass auf genau dieses Konto zu erstatten ist. Der Teili entscheidet dann nur
noch, ob er es haben oder spenden möchte.
-->
<div v-if="knownRefundAccount" class="refund-known-account">
<strong>Konto aus dem Zahlungseingang</strong>
<span>{{ knownRefundAccount.accountOwner }}</span>
<span class="refund-known-account__iban">{{ knownRefundAccount.accountIban }}</span>
<span class="refund-known-account__source">{{ knownRefundAccount.source }}</span>
</div>
<div class="refund-field">
<label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" />
Teilnehmer*in trägt die Bankverbindung selbst ein
<template v-if="knownRefundAccount">
Teilnehmer*in bestätigt die Erstattung oder spendet
</template>
<template v-else>
Teilnehmer*in trägt die Bankverbindung selbst ein
</template>
</label>
<label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" />
Bankverbindung liegt mir vor
<template v-if="knownRefundAccount">
Sofort einreichen, ohne auf die Rückmeldung zu warten
</template>
<template v-else>
Bankverbindung liegt mir vor
</template>
</label>
<label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" />
@@ -825,7 +863,13 @@ function mailToGroup(groupKey) {
eingereicht; der Teili erhält den Beleg per E-Mail.
</p>
<template v-if="refundForm.captureMode === 'management'">
<!-- Bekanntes Konto: nichts einzugeben, es wird ohnehin dieses genommen. -->
<p v-if="refundForm.captureMode === 'management' && knownRefundAccount" class="refund-hint">
Die Erstattung wird sofort auf das oben genannte Konto eingereicht. Der Teili erhält den
Beleg per E-Mail, wird aber nicht mehr nach einer Spende gefragt.
</p>
<template v-if="refundForm.captureMode === 'management' && !knownRefundAccount">
<div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label>
<input
@@ -882,6 +926,31 @@ function mailToGroup(groupKey) {
color: #4b5563;
}
/* Anzeige, kein Feld: bewusst nicht wie ein Eingabeblock gesetzt, damit gar nicht erst der Eindruck
entsteht, hier ließe sich etwas ändern. */
.refund-known-account {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: #f9fafb;
font-size: 0.9rem;
}
.refund-known-account__iban {
font-family: monospace;
letter-spacing: 0.04em;
color: #4b5563;
}
.refund-known-account__source {
color: #6b7280;
font-size: 0.8rem;
}
.refund-field {
margin-bottom: 14px;
}
@@ -61,8 +61,16 @@ class AcceptRefundCommand
return $response;
}
$owner = trim($this->request->accountOwner);
$iban = Iban::normalize($this->request->accountIban);
// Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
// Freigabe festgeschrieben. Dann gilt es -- und die Felder aus dem Request werden verworfen.
//
// Das ist der eigentliche Gewinn der ganzen Übung: Der Teili tippt keine IBAN mehr ab, also
// kann er sich weder vertippen noch lässt sich über einen direkten Aufruf dieser Route eine
// fremde IBAN unterschieben. Erstattet wird auf das Konto, von dem gezahlt wurde, Punkt.
$accountIsKnown = !$this->request->donation && filled($refund->account_iban);
$owner = $accountIsKnown ? (string) $refund->account_owner : trim($this->request->accountOwner);
$iban = $accountIsKnown ? (string) $refund->account_iban : Iban::normalize($this->request->accountIban);
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
@@ -83,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.';
}
}
}
@@ -8,8 +8,15 @@ class AcceptRefundRequest
{
public function __construct(
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
public readonly string $accountIban,
/**
* Die Eingaben des Teilis -- leer, wenn es nichts einzugeben gab.
*
* Das ist der Normalfall bei einer Spende (kein Konto nötig) und bei einem bereits bekannten
* Konto: Dort steht die Bankverbindung seit der Freigabe am Vorgang, und der Command nimmt
* ausschließlich diese.
*/
public readonly string $accountOwner = '',
public readonly string $accountIban = '',
/**
* Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts.
*
@@ -2,6 +2,7 @@
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Enumerations\RefundAccountSource;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
@@ -30,6 +31,12 @@ class CreateRefundDocumentCommand
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
/**
* Ihre Fassung für Zahlungsarten ohne Ursprungskonto (Barzahlung): Dort gab es kein Konto, von dem
* der Beitrag kam -- erklärt wird stattdessen, dass das angegebene auf den eigenen Namen läuft.
*/
public const string OWN_ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
@@ -152,6 +159,18 @@ class CreateRefundDocumentCommand
);
}
/**
* Die Herkunft des Erstattungskontos, abgeleitet aus der Zahlungsart der Anmeldung.
*
* Der Beleg wird bei jedem Abruf neu gerendert und nicht gespeichert. Änderte jemand nachträglich
* die Zahlungsart, zeigte ein Nachdruck die jeweils andere Kontoerklärung. Praktisch passiert das
* nicht -- eine eigene Spalte am Vorgang wäre dafür unverhältnismäßig.
*/
private function accountSource(): RefundAccountSource
{
return $this->participant->refundData()->source;
}
private function declarationText(): string
{
if ($this->request->donation) {
@@ -164,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.'
) . '<br /><br />' . $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.'
);
}
@@ -6,6 +6,7 @@ use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
@@ -29,6 +30,8 @@ class ReleaseRefundCommand
private ParticipantRefundRepository $refunds;
private ?GetRefundDataResponse $knownRefundData = null;
public function __construct(private readonly ReleaseRefundRequest $request)
{
$this->participant = $request->participant;
@@ -63,6 +66,10 @@ class ReleaseRefundCommand
'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(),
// Kennt die Zahlungsart das Konto, von dem der Beitrag kam, steht es von Anfang an
// fest. Der Teili entscheidet dann nur noch: auszahlen oder spenden.
'account_owner' => $this->knownAccountOwner(),
'account_iban' => $this->knownAccountIban(),
'released_by' => currentUser()?->id,
'released_at' => now(),
]);
@@ -89,6 +96,31 @@ class ReleaseRefundCommand
return $response;
}
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Lazy und einmalig, weil der Aufruf über die Event-Relation des Teilnehmers läuft
* ({@see EventParticipant::paymentConfiguration()}) und in einem Vorgang mehrfach gebraucht wird.
*/
private function knownRefundData(): GetRefundDataResponse
{
return $this->knownRefundData ??= $this->participant->refundData();
}
/**
* Bei der Sofort-Einreichung und bei der Spende bleibt das Feld leer: Dort setzt der
* {@see AcceptRefundCommand} es -- aus der Eingabe der Aktionsleitung bzw. auf `null`.
*/
private function knownAccountOwner(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountOwner);
}
private function knownAccountIban(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountIban);
}
/**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
*
@@ -3,16 +3,20 @@
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Enumerations\RefundAccountSource;
use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use App\Support\Iban;
use Inertia\Response;
/**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
*
* Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert
* Liefert ausschließlich Anzeigedaten -- niemals die vollständige Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
* Ist das Konto aus dem Zahlungseingang bekannt, geht es deshalb **maskiert** hinaus: genug zum
* Wiedererkennen, zu wenig zum Mitschreiben.
*/
class RefundPageController extends CommonController
{
@@ -58,6 +62,26 @@ class RefundPageController extends CommonController
]);
}
return array_merge($common, ['state' => 'open']);
// Woher das Erstattungskonto kommt, entscheidet die Zahlungsart -- und damit, was die Seite
// fragt: gar nichts (Konto liegt vor), die Herkunftsfrage (es gab ein Ursprungskonto) oder nur
// die Felder (Barzahlung, es gab nie eines).
$accountSource = $refund->account_iban !== null
? RefundAccountSource::Known
: $participant->refundData()->source;
return array_merge($common, [
'state' => 'open',
'accountSource' => $accountSource->value,
// Der Name des page_texts-Eintrags, nicht der Text: Welche Erklärung gilt, entscheidet der
// Server -- sonst könnten Seite und Beleg auseinanderlaufen.
'accountDeclarationText' => $accountSource->accountDeclarationText(),
// Ist das Konto bekannt (aus dem Zahlungseingang), braucht der Teili es nicht einzugeben --
// er soll es aber wiedererkennen können, um einen Fehler zu melden. Deshalb maskiert:
// Die vollständige Bankverbindung verlässt diese Seite weiterhin nicht.
'knownAccount' => $refund->account_iban === null ? null : [
'owner' => $refund->account_owner,
'ibanMasked' => Iban::mask($refund->account_iban),
],
]);
}
}
@@ -28,8 +28,35 @@ const props = defineProps({
reasonNote: String,
acceptedAt: String,
donation: Boolean,
/*
* Das Konto aus dem Zahlungseingang, falls bekannt: {owner, ibanMasked}.
*
* Ist es gesetzt, wird es nicht mehr abgefragt -- es steht bereits am Vorgang und der Server
* nimmt ohnehin nur dieses. Die IBAN kommt maskiert an: zum Wiedererkennen genug, zum
* Mitschreiben zu wenig.
*/
knownAccount: {type: Object, default: null},
/*
* Woher das Erstattungskonto kommt -- entschieden von der Zahlungsart, nicht hier:
* known -- liegt vor, wird nur bestätigt
* origin -- es gab ein Ursprungskonto, wir fragen danach
* none -- es gab nie eines (Barzahlung); dann ist die Herkunftsfrage sinnlos
*/
accountSource: {type: String, default: 'origin'},
/** Name des page_texts-Eintrags mit der zweiten Erklärung -- der Server wählt ihn. */
accountDeclarationText: {type: String, default: 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'},
})
const hasKnownAccount = computed(() => props.accountSource === 'known')
/**
* Ob nach dem Ursprungskonto gefragt wird.
*
* Nur wenn es eines gab: Wer bar gezahlt hat, könnte die Frage nicht sinnvoll beantworten -- und landete
* bei „Nein" in einer Sackgasse, die ihn auffordert, das Konto zu nennen, von dem überwiesen wurde.
*/
const asksForOrigin = computed(() => props.accountSource === 'origin')
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
@@ -70,6 +97,32 @@ const accountComplete = computed(
const isDonation = computed(() => decision.value === 'donation')
/**
* Wann die Erklärungen des Auszahlungswegs erscheinen.
*
* Bekanntes Konto: sofort -- es steht ja schon da. Sonst erst, wenn die Kontoangaben vollständig sind;
* wo nach dem Ursprungskonto gefragt wird, zusätzlich erst nach einem „Ja".
*/
const showDeclarations = computed(() => {
if (decision.value !== 'payout') return false
if (hasKnownAccount.value) return true
if (!accountComplete.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wann nach Kontoinhaber*in und IBAN gefragt wird.
*
* Überall dort, wo das Konto nicht schon feststeht -- beim Ursprungskonto-Weg allerdings erst, nachdem
* die Herkunftsfrage bejaht wurde: Sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
*/
const showAccountFields = computed(() => {
if (decision.value !== 'payout' || hasKnownAccount.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
@@ -95,8 +148,13 @@ function validate() {
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) {
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.
-->
<template v-else-if="decision === 'payout'">
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
<!--
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="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<div class="known-account">
<span class="known-account__owner">{{ knownAccount.owner }}</span>
<span class="known-account__iban">{{ knownAccount.ibanMasked }}</span>
</div>
<template v-if="sameAccount === false">
<p class="hint">
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 &ndash; etwa dem eines Elternteils
&ndash;, 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 }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
<template v-else-if="sameAccount === true">
<!--
Kein Ursprungskonto (bar gezahlt): Die Herkunftsfrage entfällt. Sonst
landete der Teili bei Nein" in einer Sackgasse, die ihn nach dem Konto
fragt, von dem überwiesen wurde -- es gab keines. Erstattet wird auf ein
Konto, das ihm gehört.
-->
<template v-else-if="!asksForOrigin">
<h3>Auf welches Konto sollen wir erstatten?</h3>
<p class="choice-hint">
Deinen Beitrag hast du nicht überwiesen, deshalb brauchen wir eine
Bankverbindung von dir. Bitte gib ein Konto an, das auf deinen Namen
läuft oder über das du verfügen darfst.
</p>
</template>
<template v-else>
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<template v-if="sameAccount === false">
<p class="hint">
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 &ndash; etwa dem eines Elternteils
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
</template>
<!--
Die Kontoangaben -- gleich in beiden Wegen, in denen sie erfragt werden:
nach einem „Ja" auf die Herkunftsfrage und bei einer Zahlungsart ohne
Ursprungskonto. Nur beim bereits bekannten Konto entfallen sie.
-->
<template v-if="showAccountFields">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
@@ -312,13 +423,17 @@ async function submit() {
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
</template>
<!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat.
In beiden Wegen dieselben: Beim bekannten Konto bestätigt der Teili
Angezeigtes statt Getipptes -- der Beleg bleibt dadurch unverändert.
-->
<template v-if="accountComplete">
<template v-if="showDeclarations">
<div class="declaration">
<input
id="refund-declaration"
@@ -339,7 +454,7 @@ async function submit() {
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT"
:text-name="accountDeclarationText"
belongs-to="refund-account-declaration"
/>
</div>
@@ -356,7 +471,6 @@ async function submit() {
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</template>
</template>
</template>
</form>
</template>
@@ -429,6 +543,31 @@ h3 {
font-weight: bold;
}
/* Das bekannte Konto -- Anzeige, kein Feld. Bewusst wie ein Beleg gesetzt und nicht wie ein Formular,
damit gar nicht erst der Eindruck entsteht, hier ließe sich etwas ändern. */
.known-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
margin-bottom: 4px;
}
.known-account__owner {
font-weight: bold;
color: #374151;
}
.known-account__iban {
font-family: monospace;
font-size: 1.05rem;
letter-spacing: 0.05em;
color: #4b5563;
}
.field {
margin-bottom: 16px;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Enumerations;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
/**
* Woher das Konto kommt, auf das eine Erstattung geht -- die Antwort hängt an der Zahlungsart.
*
* Die Unterscheidung trägt eine Kontrolle: Erstattet wird auf das Konto, von dem der Beitrag kam, damit
* sich über eine Erstattung kein Geld auf ein fremdes Konto umleiten lässt. Wo es nie ein solches Konto
* gab (Barzahlung), kann diese Kontrolle nicht greifen -- dort tritt an ihre Stelle die Erklärung, dass
* das angegebene Konto auf den eigenen Namen läuft.
*
* Reines Code-Enum wie {@see VatPricingMode}: Es wird zur Laufzeit aus der Zahlungsart abgeleitet und
* nirgends gespeichert.
*/
enum RefundAccountSource: string
{
/** Das Konto liegt vor -- aus dem Zahlungseingang übernommen. Es wird nicht mehr erfragt. */
case Known = 'known';
/** Es gab ein Ursprungskonto, wir kennen es nicht. Der Teili gibt es an und bestätigt die Herkunft. */
case Origin = 'origin';
/** Es gab nie ein Konto (Barzahlung). Der Teili gibt eines an, das auf seinen Namen läuft. */
case None = 'none';
/** Ob die Kontoangaben vom Teili erfragt werden müssen. */
public function needsInput(): bool
{
return $this !== self::Known;
}
/**
* Der `page_texts`-Eintrag mit der zweiten Erklärung des Auszahlungswegs.
*
* Hier und nicht in Seite bzw. Beleg getrennt, damit beide denselben Wortlaut zeigen: Was der Teili
* gelesen hat, muss wörtlich das sein, was das PDF ihm zuschreibt.
*/
public function accountDeclarationText(): string
{
return $this === self::None
? CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT
: CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT;
}
}
@@ -7,6 +7,9 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -192,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).
+32 -4
View File
@@ -7,12 +7,13 @@ 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`, `ProvidesStatementRuleset`, `ReadsBankStatements`**Fähigkeits-Interfaces** (siehe unten).
@@ -64,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)
@@ -153,8 +180,9 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Unit/BankStatementRulesetTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`.
`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:
@@ -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;
}
}
@@ -6,6 +6,8 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -68,4 +70,16 @@ interface EventPaymentModule
* (In dieser Iteration nur als Stub vorhanden.)
*/
public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse;
/**
* Auf welches Konto wäre zu erstatten -- und wissen wir es überhaupt?
*
* Gehört ins Kern-Interface und nicht in ein Fähigkeits-Interface, weil jede Zahlungsart eine
* Antwort darauf hat; sie fällt nur unterschiedlich aus. Barzahlung: keine. Überweisung: das
* Konto, von dem der Beitrag kam. SEPA-Lastschrift später: das Konto des Mandats.
*
* Standard ist „nicht bekannt" -- dann läuft die Erstattung wie gehabt über die Angaben des
* Teilis bzw. der Aktionsleitung.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse;
}
@@ -2,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;
@@ -108,6 +111,41 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
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
{
@@ -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(
@@ -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,
],
);
}
+19
View File
@@ -8,6 +8,8 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -238,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()));
}
}
@@ -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;
@@ -79,6 +80,7 @@ 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',
@@ -124,4 +126,28 @@ class EventParticipantResource extends JsonResource
return $refund?->toResource()->toArray($request);
}
/**
* Das Konto, auf das erstattet würde -- sofern die Zahlungsart es kennt.
*
* Unmaskiert: Diese Resource speist die Teilnehmerliste der Aktionsleitung, die IBANs ohnehin
* sehen und eingeben darf. Auf der öffentlichen Erstattungsseite geht dieselbe Angabe maskiert
* hinaus, dort besorgt das der RefundPageController.
*
* @return array<string, mixed>
*/
private function refundData(): array
{
$refundData = $this->resource->refundData();
return [
// `available` heißt für das Frontend: Das Konto steht fest, es wird nicht mehr erfragt.
'available' => $refundData->hasAccount(),
'accountOwner' => $refundData->accountOwner,
'accountIban' => $refundData->accountIban === null
? null
: Iban::format($refundData->accountIban),
'source' => $refundData->sourceNote,
];
}
}
+35
View File
@@ -57,6 +57,41 @@ final class Iban
return trim(chunk_split(self::normalize($iban), 4, ' '));
}
/**
* Nur Land, Prüfziffer und die letzten vier Stellen -- der Rest wird zu Punkten.
*
* Für Seiten, die über einen Token aus einer Mail erreichbar sind: Die eigene Bankverbindung
* erkennt man daran wieder, wer den Token aus einem fremden Postfach fischt, liest sie aber
* nicht mit.
*/
public static function mask(string $iban): string
{
$iban = self::normalize($iban);
// Zu kurz zum Maskieren wäre auch zu kurz zum Erkennen -- dann lieber alles verdecken.
$masked = strlen($iban) <= 8
? str_repeat('•', max(strlen($iban), 1))
: substr($iban, 0, 4) . str_repeat('•', strlen($iban) - 8) . substr($iban, -4);
return self::group($masked);
}
/**
* In Vierergruppen, zeichen- statt byteweise.
*
* `chunk_split()` zählt Bytes und würde das drei Byte lange „•" mitten durchschneiden -- heraus
* käme Zeichensalat.
*
* @param string $value
*/
private static function group(string $value): string
{
return implode(' ', array_map(
static fn (array $chunk): string => implode('', $chunk),
array_chunk(mb_str_split($value), 4),
));
}
/**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
@@ -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();
}
};
@@ -24,25 +24,67 @@
@endif
</table>
<p>
Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
folgenden Link ein:
</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-left: 3px solid #f5c400; background-color: #fffef5;">
<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;,
gib bitte dieses an.
</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>
Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine
Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
</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>
<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>
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
folgenden Link ein:
</p>
@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
gezahlt wurde. Wurde er von einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils &ndash;,
gib bitte dieses an.
</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>
Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine
Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
</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>
</p>
@endif
<p style="font-size: 12px; color: #555;">
Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br />
+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);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Tests\Unit;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule;
use App\Models\EventParticipant;
use App\Support\Iban;
use Carbon\CarbonImmutable;
use Tests\TestCase;
/**
* Auf welches Konto wäre zu erstatten -- die Frage, die jede Zahlungsart beantwortet.
*
* Ohne Datenbank: Die Module bekommen den Teilnehmer und ihre Konfiguration hereingereicht und
* fragen selbst nichts ab. Den Framework-Kontext braucht der Test trotzdem, weil der `date`-Cast
* des Models sein Format über die Verbindung auflöst.
*/
class RefundDataTest extends TestCase
{
private const string VALID_IBAN = 'DE02120300000000202051';
private function participant(array $paymentOptions, ?string $lastPaymentDate = null): EventParticipant
{
$participant = new EventParticipant();
$participant->setRawAttributes(['firstname' => 'Max', 'lastname' => 'Meier'], true);
$participant->payment_options = $paymentOptions;
$participant->last_payment_date = $lastPaymentDate === null
? null
: CarbonImmutable::createFromFormat('!Y-m-d', $lastPaymentDate);
return $participant;
}
private function refundData(EventParticipant $participant)
{
return new AccountTransferPaymentModule()->getRefundData(
new GetRefundDataRequest(participant: $participant)
);
}
public function test_the_payer_account_from_the_bank_import_is_offered(): void
{
$response = $this->refundData($this->participant([
'payer_iban' => self::VALID_IBAN,
'payer_account_owner' => 'Max Meier',
], '2026-09-09'));
$this->assertSame(RefundAccountSource::Known, $response->source);
$this->assertTrue($response->hasAccount());
$this->assertSame(self::VALID_IBAN, $response->accountIban);
$this->assertSame('Max Meier', $response->accountOwner);
$this->assertSame('Zahlungseingang vom 09.09.2026', $response->sourceNote);
}
public function test_the_iban_is_normalized(): void
{
$response = $this->refundData($this->participant([
'payer_iban' => 'de02 1203 0000 0000 2020 51',
'payer_account_owner' => 'Max Meier',
]));
$this->assertSame(self::VALID_IBAN, $response->accountIban);
}
/**
* Eine IBAN mit Zahlendreher würde ungeprüft übernommen und das Geld ginge an eine fremde Person.
* Lieber gar nichts melden -- dann fragt die Erstattung wie bisher nach dem Ursprungskonto.
*/
public function test_an_invalid_iban_falls_back_to_asking(): void
{
$response = $this->refundData($this->participant([
'payer_iban' => 'DE02120300000000202015',
'payer_account_owner' => 'Max Meier',
]));
$this->assertSame(RefundAccountSource::Origin, $response->source);
$this->assertNull($response->accountIban);
}
/** Ohne Inhaber lässt sich nicht auszahlen -- der SEPA-Export braucht beides. */
public function test_a_missing_owner_falls_back_to_asking(): void
{
$response = $this->refundData($this->participant(['payer_iban' => self::VALID_IBAN]));
$this->assertSame(RefundAccountSource::Origin, $response->source);
}
/**
* Überweiser ohne erfassten Zahlungseingang: Es gab ein Ursprungskonto, wir kennen es nur nicht --
* der Teili wird danach gefragt.
*/
public function test_a_transfer_without_payment_data_asks_for_the_origin_account(): void
{
$response = $this->refundData($this->participant([]));
$this->assertSame(RefundAccountSource::Origin, $response->source);
$this->assertTrue($response->source->needsInput());
}
/**
* Barzahlung: Es gab nie ein Konto. Die Frage nach dem Ursprungskonto wäre sinnlos, und die
* Erklärung, es sei dasselbe Konto, unwahr -- deshalb ein eigener Fall, kein „unbekannt".
*/
public function test_cash_payment_has_no_origin_account(): void
{
$response = new UndefinedPaymentModule()->getRefundData(new GetRefundDataRequest(
// Selbst wenn zufällig Zahlerdaten in den Optionen stünden: bar ist bar.
participant: $this->participant([
'payer_iban' => self::VALID_IBAN,
'payer_account_owner' => 'Max Meier',
]),
));
$this->assertSame(RefundAccountSource::None, $response->source);
$this->assertNull($response->accountIban);
$this->assertTrue($response->source->needsInput());
}
/** Die Vorgabe der Basisklasse ist die strengere Annahme: Es gab ein Ursprungskonto. */
public function test_the_default_assumes_an_origin_account(): void
{
$this->assertSame(RefundAccountSource::Origin, new GetRefundDataResponse()->source);
}
/** Welche Erklärung gilt, hängt allein an der Herkunft -- eine Quelle für Seite und Beleg. */
public function test_the_declaration_text_follows_the_source(): void
{
$this->assertSame(
CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT,
RefundAccountSource::Origin->accountDeclarationText(),
);
$this->assertSame(
CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT,
RefundAccountSource::Known->accountDeclarationText(),
);
$this->assertSame(
CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT,
RefundAccountSource::None->accountDeclarationText(),
);
}
/**
* Gruppiert wie {@see Iban::format()}, damit beide Schreibweisen nebeneinander stimmig aussehen:
* `DE02 1203 0000 0000 2020 51` wird zu `DE02 •••• •••• •••• ••20 51`. Die letzten vier Stellen
* liegen bei 22 Zeichen über zwei Gruppen -- das ist bei der unmaskierten Form genauso.
*/
public function test_masking_keeps_country_check_digits_and_the_last_four(): void
{
$this->assertSame('DE02 •••• •••• •••• ••20 51', Iban::mask(self::VALID_IBAN));
// Leerzeichen in der Eingabe ändern nichts.
$this->assertSame('DE02 •••• •••• •••• ••20 51', Iban::mask('DE02 1203 0000 0000 2020 51'));
// Die Mitte ist wirklich weg -- niemand liest die Kontonummer aus der Maske heraus.
$this->assertStringNotContainsString('1203', Iban::mask(self::VALID_IBAN));
}
/** Zu kurz zum Erkennen ist auch zu kurz zum Maskieren -- dann lieber alles verdecken. */
public function test_masking_hides_everything_when_there_is_too_little(): void
{
$this->assertSame('•••• ••••', Iban::mask('DE021203'));
$this->assertSame('•', Iban::mask(''));
}
/** Die Punkte sind Mehrbyte-Zeichen -- byteweises Gruppieren zerschnitte sie zu Zeichensalat. */
public function test_masking_produces_valid_utf8(): void
{
$this->assertTrue(mb_check_encoding(Iban::mask(self::VALID_IBAN), 'UTF-8'));
}
}