Kontoinhaber*in
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 @@
+ $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/EventPaymentModule.php b/app/EventPaymentModules/EventPaymentModule.php
index 503af88..05ea681 100644
--- a/app/EventPaymentModules/EventPaymentModule.php
+++ b/app/EventPaymentModules/EventPaymentModule.php
@@ -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;
}
diff --git a/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php b/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php
index faef484..4edd798 100644
--- a/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php
+++ b/app/EventPaymentModules/Modules/AccountTransferPaymentModule.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;
@@ -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
{
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/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/EventParticipant.php b/app/Models/EventParticipant.php
index b7bc21b..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;
@@ -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()));
+ }
}
diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php
index 1258e2a..42eaa5a 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;
@@ -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
+ */
+ 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/Support/Iban.php b/app/Support/Iban.php
index 9f4a15c..3294765 100644
--- a/app/Support/Iban.php
+++ b/app/Support/Iban.php
@@ -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.
diff --git a/database/migrations/2026_09_14_140010_add_refund_own_account_text.php b/database/migrations/2026_09_14_140010_add_refund_own_account_text.php
new file mode 100644
index 0000000..9c1f6a5
--- /dev/null
+++ b/database/migrations/2026_09_14_140010_add_refund_own_account_text.php
@@ -0,0 +1,46 @@
+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();
+ }
+};
diff --git a/resources/views/emails/events/refund_released.blade.php b/resources/views/emails/events/refund_released.blade.php
index 806296f..6e41229 100644
--- a/resources/views/emails/events/refund_released.blade.php
+++ b/resources/views/emails/events/refund_released.blade.php
@@ -24,25 +24,67 @@
@endif
-
- Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
- folgenden Link ein:
-
+@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.
+ --}}
+
+ Wir erstatten den Betrag auf das Konto, von dem dein Teilnahmebeitrag gezahlt wurde:
+
-
- Wichtig: Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
- gezahlt wurde. Wurde er von einem anderen Konto überwiesen – etwa dem eines Elternteils –,
- gib bitte dieses an.
-
+
+ {{$knownAccountOwner}}
+ {{$knownAccountIban}}
+
-
- Du kannst den Betrag stattdessen auch spenden . Dann brauchen wir keine
- Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
-
+
+ Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das Konto nicht zu deiner
+ Zahlung, wende dich bitte an {{$eventEmail}}.
+
-
- Bankverbindung eintragen oder spenden
-
+
+ Du kannst den Betrag stattdessen auch spenden . Dann verbleibt er beim Verband
+ und kommt unserer Arbeit zugute. Bitte sag uns über den folgenden Link kurz Bescheid, wie du es
+ möchtest:
+
+
+
+ Erstattung bestätigen oder spenden
+
+@else
+
+ Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
+ folgenden Link ein:
+
+
+ @if($hasOriginAccount)
+
+ Wichtig: Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
+ gezahlt wurde. Wurde er von einem anderen Konto überwiesen – etwa dem eines Elternteils –,
+ gib bitte dieses an.
+
+ @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.
+ --}}
+
+ Wichtig: 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.
+
+ @endif
+
+
+ Du kannst den Betrag stattdessen auch spenden . Dann brauchen wir keine
+ Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
+
+
+
+ Bankverbindung eintragen oder spenden
+
+@endif
Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:
diff --git a/tests/Feature/RefundCashPayerTest.php b/tests/Feature/RefundCashPayerTest.php
new file mode 100644
index 0000000..781b40f
--- /dev/null
+++ b/tests/Feature/RefundCashPayerTest.php
@@ -0,0 +1,347 @@
+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' => '
{block:body}
',
+ 'sort_order' => 10,
+ ]);
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_BODY,
+ 'content' => '{details_table}{declaration_text}
',
+ '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);
+ }
+}
diff --git a/tests/Feature/RefundKnownAccountTest.php b/tests/Feature/RefundKnownAccountTest.php
new file mode 100644
index 0000000..8d3afaf
--- /dev/null
+++ b/tests/Feature/RefundKnownAccountTest.php
@@ -0,0 +1,375 @@
+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' => '{block:body}
',
+ 'sort_order' => 10,
+ ]);
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_BODY,
+ 'content' => '{details_table}{declaration_text}
',
+ '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);
+ }
+}
diff --git a/tests/Unit/RefundDataTest.php b/tests/Unit/RefundDataTest.php
new file mode 100644
index 0000000..f667977
--- /dev/null
+++ b/tests/Unit/RefundDataTest.php
@@ -0,0 +1,174 @@
+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'));
+ }
+}