Dev 4.9.0 #17

Merged
th.guenther merged 6 commits from dev-4.9.0 into main 2026-09-10 19:23:18 +02:00
24 changed files with 1692 additions and 83 deletions
Showing only changes of commit 6301c342e4 - Show all commits
@@ -86,6 +86,16 @@ const retainedAmount = computed(() => {
const hasRetention = computed(() => retainedAmount.value > 0); const hasRetention = computed(() => retainedAmount.value > 0);
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Ist es da, wird es weder erfragt noch bearbeitet: Erstattet wird auf das Konto, von dem der Beitrag
* kam, und genau das steht hier. `null`, solange nichts bekannt ist (Barzahlung, Altbestand).
*/
const knownRefundAccount = computed(
() => showParticipant.value?.refundData?.available ? showParticipant.value.refundData : null
);
const retainedAmountReadable = computed( const retainedAmountReadable = computed(
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro' () => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro'
); );
@@ -121,7 +131,8 @@ const refundFormComplete = computed(() => {
} }
} }
if (refundForm.captureMode === 'management') { // Beim bekannten Konto gibt es nichts einzugeben -- es steht fest.
if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== ''; return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== '';
} }
@@ -429,7 +440,7 @@ function validateRefund() {
// Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen. // Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge. // Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
if (refundForm.captureMode === 'management') { if (refundForm.captureMode === 'management' && !knownRefundAccount.value) {
if (!refundForm.accountOwner.trim()) { if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.'; refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
} }
@@ -456,9 +467,14 @@ async function execRefund() {
amount: refundForm.amount, amount: refundForm.amount,
reason: refundForm.reason, reason: refundForm.reason,
reasonNote: refundForm.reasonNote, reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. Beim
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', // Sofort-Einreichen mit bekanntem Konto geht dieses mit; eingegeben wurde nichts.
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', accountOwner: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountOwner ?? refundForm.accountOwner)
: '',
accountIban: refundForm.captureMode === 'management'
? (knownRefundAccount.value?.accountIban ?? refundForm.accountIban)
: '',
// Spende: kein Konto, trotzdem sofort eingereicht. // Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation', donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen. // Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
@@ -805,14 +821,36 @@ function mailToGroup(groupKey) {
Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn: Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn:
die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem. die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem.
--> -->
<!--
Ist das Konto aus dem Zahlungseingang bekannt, wird es weder erfragt noch bearbeitet: Es
steht fest, dass auf genau dieses Konto zu erstatten ist. Der Teili entscheidet dann nur
noch, ob er es haben oder spenden möchte.
-->
<div v-if="knownRefundAccount" class="refund-known-account">
<strong>Konto aus dem Zahlungseingang</strong>
<span>{{ knownRefundAccount.accountOwner }}</span>
<span class="refund-known-account__iban">{{ knownRefundAccount.accountIban }}</span>
<span class="refund-known-account__source">{{ knownRefundAccount.source }}</span>
</div>
<div class="refund-field"> <div class="refund-field">
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" /> <input type="radio" value="participant" v-model="refundForm.captureMode" />
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>
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" /> <input type="radio" value="management" v-model="refundForm.captureMode" />
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>
<label class="refund-choice"> <label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" /> <input type="radio" value="donation" v-model="refundForm.captureMode" />
@@ -825,7 +863,13 @@ function mailToGroup(groupKey) {
eingereicht; der Teili erhält den Beleg per E-Mail. eingereicht; der Teili erhält den Beleg per E-Mail.
</p> </p>
<template v-if="refundForm.captureMode === 'management'"> <!-- Bekanntes Konto: nichts einzugeben, es wird ohnehin dieses genommen. -->
<p v-if="refundForm.captureMode === 'management' && knownRefundAccount" class="refund-hint">
Die Erstattung wird sofort auf das oben genannte Konto eingereicht. Der Teili erhält den
Beleg per E-Mail, wird aber nicht mehr nach einer Spende gefragt.
</p>
<template v-if="refundForm.captureMode === 'management' && !knownRefundAccount">
<div class="refund-field"> <div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label> <label for="refund_account_owner">Kontoinhaber*in</label>
<input <input
@@ -882,6 +926,31 @@ function mailToGroup(groupKey) {
color: #4b5563; color: #4b5563;
} }
/* Anzeige, kein Feld: bewusst nicht wie ein Eingabeblock gesetzt, damit gar nicht erst der Eindruck
entsteht, hier ließe sich etwas ändern. */
.refund-known-account {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: #f9fafb;
font-size: 0.9rem;
}
.refund-known-account__iban {
font-family: monospace;
letter-spacing: 0.04em;
color: #4b5563;
}
.refund-known-account__source {
color: #6b7280;
font-size: 0.8rem;
}
.refund-field { .refund-field {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -61,8 +61,16 @@ class AcceptRefundCommand
return $response; return $response;
} }
$owner = trim($this->request->accountOwner); // Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
$iban = Iban::normalize($this->request->accountIban); // Freigabe festgeschrieben. Dann gilt es -- und die Felder aus dem Request werden verworfen.
//
// Das ist der eigentliche Gewinn der ganzen Übung: Der Teili tippt keine IBAN mehr ab, also
// kann er sich weder vertippen noch lässt sich über einen direkten Aufruf dieser Route eine
// fremde IBAN unterschieben. Erstattet wird auf das Konto, von dem gezahlt wurde, Punkt.
$accountIsKnown = !$this->request->donation && filled($refund->account_iban);
$owner = $accountIsKnown ? (string) $refund->account_owner : trim($this->request->accountOwner);
$iban = $accountIsKnown ? (string) $refund->account_iban : Iban::normalize($this->request->accountIban);
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg // Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf // als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
@@ -83,14 +91,19 @@ class AcceptRefundCommand
. 'von dem der Beitrag gezahlt wurde.'; . 'von dem der Beitrag gezahlt wurde.';
} }
if ($owner === '') { // Nur was der Teili selbst eingibt, muss geprüft werden. Das bekannte Konto hat die
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.'; // 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 === '') { if ($iban === '') {
$response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.'; $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
} elseif (!Iban::isValid($iban)) { } elseif (!Iban::isValid($iban)) {
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.'; $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
}
} }
} }
@@ -8,8 +8,15 @@ class AcceptRefundRequest
{ {
public function __construct( public function __construct(
public readonly ?ParticipantRefund $refund, public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner, /**
public readonly string $accountIban, * Die Eingaben des Teilis -- leer, wenn es nichts einzugeben gab.
*
* Das ist der Normalfall bei einer Spende (kein Konto nötig) und bei einem bereits bekannten
* Konto: Dort steht die Bankverbindung seit der Freigabe am Vorgang, und der Command nimmt
* ausschließlich diese.
*/
public readonly string $accountOwner = '',
public readonly string $accountIban = '',
/** /**
* Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. * Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts.
* *
@@ -2,6 +2,7 @@
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument; namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Enumerations\RefundAccountSource;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Models\Event; use App\Models\Event;
use App\Models\EventParticipant; use App\Models\EventParticipant;
@@ -30,6 +31,12 @@ class CreateRefundDocumentCommand
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */ /** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'; public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
/**
* Ihre Fassung für Zahlungsarten ohne Ursprungskonto (Barzahlung): Dort gab es kein Konto, von dem
* der Beitrag kam -- erklärt wird stattdessen, dass das angegebene auf den eigenen Namen läuft.
*/
public const string OWN_ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */ /** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION'; public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
@@ -152,6 +159,18 @@ class CreateRefundDocumentCommand
); );
} }
/**
* Die Herkunft des Erstattungskontos, abgeleitet aus der Zahlungsart der Anmeldung.
*
* Der Beleg wird bei jedem Abruf neu gerendert und nicht gespeichert. Änderte jemand nachträglich
* die Zahlungsart, zeigte ein Nachdruck die jeweils andere Kontoerklärung. Praktisch passiert das
* nicht -- eine eigene Spalte am Vorgang wäre dafür unverhältnismäßig.
*/
private function accountSource(): RefundAccountSource
{
return $this->participant->refundData()->source;
}
private function declarationText(): string private function declarationText(): string
{ {
if ($this->request->donation) { if ($this->request->donation) {
@@ -164,14 +183,22 @@ class CreateRefundDocumentCommand
// Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie // Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
// gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist. // gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
//
// Welche der beiden Kontoerklärungen gilt, sagt die Zahlungsart: Wer bar gezahlt hat, kann
// nicht bestätigen, dass das Konto dasselbe ist -- es gab keines.
$accountSource = $this->accountSource();
return $this->pageText( return $this->pageText(
self::DECLARATION_TEXT, self::DECLARATION_TEXT,
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig ' 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.' . 'zurückerstattet bekomme.'
) . '<br /><br />' . $this->pageText( ) . '<br /><br />' . $this->pageText(
self::ACCOUNT_DECLARATION_TEXT, $accountSource->accountDeclarationText(),
'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag ' $accountSource === RefundAccountSource::None
. 'gezahlt wurde.' ? '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\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason; use App\Enumerations\RetentionReason;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\Mail\ParticipantRefundMails\RefundReleasedMail; use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
@@ -29,6 +30,8 @@ class ReleaseRefundCommand
private ParticipantRefundRepository $refunds; private ParticipantRefundRepository $refunds;
private ?GetRefundDataResponse $knownRefundData = null;
public function __construct(private readonly ReleaseRefundRequest $request) public function __construct(private readonly ReleaseRefundRequest $request)
{ {
$this->participant = $request->participant; $this->participant = $request->participant;
@@ -63,6 +66,10 @@ class ReleaseRefundCommand
'retained_amount' => $this->request->retainedAmount(), 'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(), 'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(), 'retention_reason_note' => $this->retentionReasonNote(),
// Kennt die Zahlungsart das Konto, von dem der Beitrag kam, steht es von Anfang an
// fest. Der Teili entscheidet dann nur noch: auszahlen oder spenden.
'account_owner' => $this->knownAccountOwner(),
'account_iban' => $this->knownAccountIban(),
'released_by' => currentUser()?->id, 'released_by' => currentUser()?->id,
'released_at' => now(), 'released_at' => now(),
]); ]);
@@ -89,6 +96,31 @@ class ReleaseRefundCommand
return $response; return $response;
} }
/**
* Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs.
*
* Lazy und einmalig, weil der Aufruf über die Event-Relation des Teilnehmers läuft
* ({@see EventParticipant::paymentConfiguration()}) und in einem Vorgang mehrfach gebraucht wird.
*/
private function knownRefundData(): GetRefundDataResponse
{
return $this->knownRefundData ??= $this->participant->refundData();
}
/**
* Bei der Sofort-Einreichung und bei der Spende bleibt das Feld leer: Dort setzt der
* {@see AcceptRefundCommand} es -- aus der Eingabe der Aktionsleitung bzw. auf `null`.
*/
private function knownAccountOwner(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountOwner);
}
private function knownAccountIban(): ?string
{
return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountIban);
}
/** /**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili. * Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
* *
@@ -3,16 +3,20 @@
namespace App\Domains\ParticipantRefund\Controllers; namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand; use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Enumerations\RefundAccountSource;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider; use App\Providers\InertiaProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\Support\Iban;
use Inertia\Response; use Inertia\Response;
/** /**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt. * Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
* *
* Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert * Liefert ausschließlich Anzeigedaten -- niemals die vollständige Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein. * durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
* Ist das Konto aus dem Zahlungseingang bekannt, geht es deshalb **maskiert** hinaus: genug zum
* Wiedererkennen, zu wenig zum Mitschreiben.
*/ */
class RefundPageController extends CommonController class RefundPageController extends CommonController
{ {
@@ -58,6 +62,26 @@ class RefundPageController extends CommonController
]); ]);
} }
return array_merge($common, ['state' => 'open']); // Woher das Erstattungskonto kommt, entscheidet die Zahlungsart -- und damit, was die Seite
// fragt: gar nichts (Konto liegt vor), die Herkunftsfrage (es gab ein Ursprungskonto) oder nur
// die Felder (Barzahlung, es gab nie eines).
$accountSource = $refund->account_iban !== null
? RefundAccountSource::Known
: $participant->refundData()->source;
return array_merge($common, [
'state' => 'open',
'accountSource' => $accountSource->value,
// Der Name des page_texts-Eintrags, nicht der Text: Welche Erklärung gilt, entscheidet der
// Server -- sonst könnten Seite und Beleg auseinanderlaufen.
'accountDeclarationText' => $accountSource->accountDeclarationText(),
// Ist das Konto bekannt (aus dem Zahlungseingang), braucht der Teili es nicht einzugeben --
// er soll es aber wiedererkennen können, um einen Fehler zu melden. Deshalb maskiert:
// Die vollständige Bankverbindung verlässt diese Seite weiterhin nicht.
'knownAccount' => $refund->account_iban === null ? null : [
'owner' => $refund->account_owner,
'ibanMasked' => Iban::mask($refund->account_iban),
],
]);
} }
} }
@@ -28,8 +28,35 @@ const props = defineProps({
reasonNote: String, reasonNote: String,
acceptedAt: String, acceptedAt: String,
donation: Boolean, donation: Boolean,
/*
* Das Konto aus dem Zahlungseingang, falls bekannt: {owner, ibanMasked}.
*
* Ist es gesetzt, wird es nicht mehr abgefragt -- es steht bereits am Vorgang und der Server
* nimmt ohnehin nur dieses. Die IBAN kommt maskiert an: zum Wiedererkennen genug, zum
* Mitschreiben zu wenig.
*/
knownAccount: {type: Object, default: null},
/*
* Woher das Erstattungskonto kommt -- entschieden von der Zahlungsart, nicht hier:
* known -- liegt vor, wird nur bestätigt
* origin -- es gab ein Ursprungskonto, wir fragen danach
* none -- es gab nie eines (Barzahlung); dann ist die Herkunftsfrage sinnlos
*/
accountSource: {type: String, default: 'origin'},
/** Name des page_texts-Eintrags mit der zweiten Erklärung -- der Server wählt ihn. */
accountDeclarationText: {type: String, default: 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'},
}) })
const hasKnownAccount = computed(() => props.accountSource === 'known')
/**
* Ob nach dem Ursprungskonto gefragt wird.
*
* Nur wenn es eines gab: Wer bar gezahlt hat, könnte die Frage nicht sinnvoll beantworten -- und landete
* bei „Nein" in einer Sackgasse, die ihn auffordert, das Konto zu nennen, von dem überwiesen wurde.
*/
const asksForOrigin = computed(() => props.accountSource === 'origin')
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden. // Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state) const state = ref(props.state)
@@ -70,6 +97,32 @@ const accountComplete = computed(
const isDonation = computed(() => decision.value === 'donation') const isDonation = computed(() => decision.value === 'donation')
/**
* Wann die Erklärungen des Auszahlungswegs erscheinen.
*
* Bekanntes Konto: sofort -- es steht ja schon da. Sonst erst, wenn die Kontoangaben vollständig sind;
* wo nach dem Ursprungskonto gefragt wird, zusätzlich erst nach einem „Ja".
*/
const showDeclarations = computed(() => {
if (decision.value !== 'payout') return false
if (hasKnownAccount.value) return true
if (!accountComplete.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wann nach Kontoinhaber*in und IBAN gefragt wird.
*
* Überall dort, wo das Konto nicht schon feststeht -- beim Ursprungskonto-Weg allerdings erst, nachdem
* die Herkunftsfrage bejaht wurde: Sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
*/
const showAccountFields = computed(() => {
if (decision.value !== 'payout' || hasKnownAccount.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/** /**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück. * Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
* *
@@ -95,8 +148,13 @@ function validate() {
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.' errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) { if (!isDonation.value) {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.' // Beim bekannten Konto gibt es nichts einzugeben und damit auch nichts zu prüfen -- nur die
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.' // 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 errors.accountDeclaration = form.accountDeclarationAccepted
? '' ? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.' : 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
@@ -111,15 +169,23 @@ async function submit() {
saving.value = true saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine. // Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
// Beim bekannten Konto gehen ebenfalls keine Kontofelder mit: Der Server nimmt ausschließlich,
// was am Vorgang steht, alles Mitgeschickte würde er verwerfen.
const body = isDonation.value const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted} ? {donation: true, declarationAccepted: form.declarationAccepted}
: { : hasKnownAccount.value
donation: false, ? {
accountOwner: form.accountOwner, donation: false,
accountIban: form.accountIban, declarationAccepted: form.declarationAccepted,
declarationAccepted: form.declarationAccepted, accountDeclarationAccepted: form.accountDeclarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted, }
} : {
donation: false,
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
try { try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', { 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. sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
--> -->
<template v-else-if="decision === 'payout'"> <template v-else-if="decision === 'payout'">
<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"> <div class="known-account">
<button <span class="known-account__owner">{{ knownAccount.owner }}</span>
type="button" <span class="known-account__iban">{{ knownAccount.ibanMasked }}</span>
class="choice" </div>
: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"> <p class="hint">
So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das
das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von Konto nicht zu deiner Zahlung, wende dich bitte an die Aktionsleitung:
einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils {{ props.eventEmail }}
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p> </p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template> </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"> <div class="field">
<label for="account-owner">Kontoinhaber*in</label> <label for="account-owner">Kontoinhaber*in</label>
<input <input
@@ -312,13 +423,17 @@ async function submit() {
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" /> <IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" /> <ErrorText :message="errors.accountIban" />
</div> </div>
</template>
<!-- <!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat. dem Teili Zusicherungen zu, die er nie abgegeben hat.
In beiden Wegen dieselben: Beim bekannten Konto bestätigt der Teili
Angezeigtes statt Getipptes -- der Beleg bleibt dadurch unverändert.
--> -->
<template v-if="accountComplete"> <template v-if="showDeclarations">
<div class="declaration"> <div class="declaration">
<input <input
id="refund-declaration" id="refund-declaration"
@@ -339,7 +454,7 @@ async function submit() {
type="checkbox" type="checkbox"
/> />
<TextResource <TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT" :text-name="accountDeclarationText"
belongs-to="refund-account-declaration" belongs-to="refund-account-declaration"
/> />
</div> </div>
@@ -356,7 +471,6 @@ async function submit() {
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }} {{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button> </button>
</template> </template>
</template>
</template> </template>
</form> </form>
</template> </template>
@@ -429,6 +543,31 @@ h3 {
font-weight: bold; font-weight: bold;
} }
/* Das bekannte Konto -- Anzeige, kein Feld. Bewusst wie ein Beleg gesetzt und nicht wie ein Formular,
damit gar nicht erst der Eindruck entsteht, hier ließe sich etwas ändern. */
.known-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
margin-bottom: 4px;
}
.known-account__owner {
font-weight: bold;
color: #374151;
}
.known-account__iban {
font-family: monospace;
font-size: 1.05rem;
letter-spacing: 0.05em;
color: #4b5563;
}
.field { .field {
margin-bottom: 16px; margin-bottom: 16px;
} }
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Enumerations;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
/**
* Woher das Konto kommt, auf das eine Erstattung geht -- die Antwort hängt an der Zahlungsart.
*
* Die Unterscheidung trägt eine Kontrolle: Erstattet wird auf das Konto, von dem der Beitrag kam, damit
* sich über eine Erstattung kein Geld auf ein fremdes Konto umleiten lässt. Wo es nie ein solches Konto
* gab (Barzahlung), kann diese Kontrolle nicht greifen -- dort tritt an ihre Stelle die Erklärung, dass
* das angegebene Konto auf den eigenen Namen läuft.
*
* Reines Code-Enum wie {@see VatPricingMode}: Es wird zur Laufzeit aus der Zahlungsart abgeleitet und
* nirgends gespeichert.
*/
enum RefundAccountSource: string
{
/** Das Konto liegt vor -- aus dem Zahlungseingang übernommen. Es wird nicht mehr erfragt. */
case Known = 'known';
/** Es gab ein Ursprungskonto, wir kennen es nicht. Der Teili gibt es an und bestätigt die Herkunft. */
case Origin = 'origin';
/** Es gab nie ein Konto (Barzahlung). Der Teili gibt eines an, das auf seinen Namen läuft. */
case None = 'none';
/** Ob die Kontoangaben vom Teili erfragt werden müssen. */
public function needsInput(): bool
{
return $this !== self::Known;
}
/**
* Der `page_texts`-Eintrag mit der zweiten Erklärung des Auszahlungswegs.
*
* Hier und nicht in Seite bzw. Beleg getrennt, damit beide denselben Wortlaut zeigen: Was der Teili
* gelesen hat, muss wörtlich das sein, was das PDF ihm zuschreibt.
*/
public function accountDeclarationText(): string
{
return $this === self::None
? CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT
: CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT;
}
}
@@ -7,6 +7,9 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\CreateInvoiceResponse; use App\EventPaymentModules\DTO\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest; use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse; use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -192,6 +195,19 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return new RegistrationSummaryResponse(); return new RegistrationSummaryResponse();
} }
/**
* Standard: Es gab ein Ursprungskonto, wir kennen es nur nicht ({@see RefundAccountSource::Origin}).
*
* Bewusst die strengere Annahme. Der Teili wird dann gefragt, ob es dasselbe Konto ist, von dem der
* Beitrag kam -- die Kontrolle, die verhindert, dass sich über eine Erstattung Geld auf ein fremdes
* Konto umleiten lässt. Eine Zahlungsart ohne Ursprungskonto (Barzahlung) muss das ausdrücklich
* sagen; stillschweigend die Kontrolle fallen zu lassen wäre die falsche Vorgabe.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
return new GetRefundDataResponse();
}
public function doPayment(DoPaymentRequest $request): DoPaymentResponse public function doPayment(DoPaymentRequest $request): DoPaymentResponse
{ {
// TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen). // TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen).
+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: - `EventPaymentModule`**Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`, `slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
`createInvoice()`. `createInvoice()`, `getRefundData()`.
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer - `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies. (`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart): - `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges). `AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges).
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`). - `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
`GetRefundData*`, `TransactionMatch`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module - `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding. hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements`**Fähigkeits-Interfaces** (siehe unten). - `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. gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.
- `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als - `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul. Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul.
- **`getRefundData()` — „auf welches Konto wäre zu erstatten, und woher kommt es?"** Steht im **Kern-Interface**, weil
jede Zahlungsart eine Antwort darauf hat; sie fällt nur unterschiedlich aus. Geantwortet wird mit
`App\Enumerations\RefundAccountSource` (reines Code-Enum, nirgends gespeichert):
- `Known` — das Konto liegt vor. Nur die Überweisung liefert das, aus `payment_options`
(`payer_iban`/`payer_account_owner`, vom Kontoauszug-Import hinterlegt) und **nur bei gültiger Prüfziffer**; eine
ungültige IBAN würde ungeprüft übernommen. `event_participants.refund_data` ist demgegenüber nur ein
**abgeleitetes Kennzeichen** für Listen und Abfragen, nie die Quelle — zwei Quellen für dieselbe Wahrheit driften
auseinander.
- `Origin` — es gab ein Ursprungskonto, wir kennen es nicht. **Vorgabe der Basisklasse**, bewusst die strengere
Annahme: Der Teili wird gefragt, ob es dasselbe Konto ist, und bestätigt die Herkunft. Das ist die Kontrolle
gegen das Umleiten einer Erstattung auf ein fremdes Konto; sie stillschweigend fallen zu lassen wäre die falsche
Vorgabe für ein künftiges Modul.
- `None` — es gab **nie** eines (`UndefinedPaymentModule`, Barzahlung). Herkunftsfrage und Herkunfts-Erklärung
wären sinnlos bzw. unwahr; an ihre Stelle tritt `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN` („läuft auf meinen
Namen"). Welcher `page_texts`-Eintrag gilt, sagt `RefundAccountSource::accountDeclarationText()` — eine Quelle
für Seite **und** Beleg.
Aufgelöst wird überall über `EventParticipant::refundData()`; verwertet in `ReleaseRefundCommand` (schreibt ein
bekanntes Konto direkt an den Vorgang, der aber `pending` bleibt — der Teili entscheidet noch über Auszahlung oder
Spende), in `AcceptRefundCommand` (ein gesetztes Konto lässt sich **nicht** aus dem Request überschreiben), im
`RefundPageController` und im Erstattungsbeleg. Auf der Token-Seite und in der Freigabe-Mail geht eine bekannte IBAN
nur maskiert hinaus (`Iban::mask()`).
- **Keine Barauszahlung.** Auch wer bar gezahlt hat, bekommt überwiesen. Rechtlich spricht nichts dagegen — das GwG
gilt für den Verband nicht (§ 2 Abs. 1 GwG; kein Güterhändler nach § 1 Abs. 9), und eine Regel „bar rein, bar raus"
existiert nicht. Die Überweisung ist zudem besser belegt: Der Kontoauszug beweist die Zahlung, während eine
Barauszahlung an einer Unterschrift hinge und die Barkasse nach § 146 AO kassensturzfähig zu halten wäre. Wer doch
bar auszahlt, bucht das über die normale Auslagenerfassung.
## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation) ## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation)
@@ -153,8 +180,9 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`, `tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`, `tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Unit/BankStatementRulesetTest`, `tests/Feature/PaymentMethodConfigurationTest`, `tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`. `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 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: 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\CreateInvoiceResponse;
use App\EventPaymentModules\DTO\DoPaymentRequest; use App\EventPaymentModules\DTO\DoPaymentRequest;
use App\EventPaymentModules\DTO\DoPaymentResponse; use App\EventPaymentModules\DTO\DoPaymentResponse;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -68,4 +70,16 @@ interface EventPaymentModule
* (In dieser Iteration nur als Stub vorhanden.) * (In dieser Iteration nur als Stub vorhanden.)
*/ */
public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse; public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse;
/**
* Auf welches Konto wäre zu erstatten -- und wissen wir es überhaupt?
*
* Gehört ins Kern-Interface und nicht in ein Fähigkeits-Interface, weil jede Zahlungsart eine
* Antwort darauf hat; sie fällt nur unterschiedlich aus. Barzahlung: keine. Überweisung: das
* Konto, von dem der Beitrag kam. SEPA-Lastschrift später: das Konto des Mandats.
*
* Standard ist „nicht bekannt" -- dann läuft die Erstattung wie gehabt über die Angaben des
* Teilis bzw. der Aktionsleitung.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse;
} }
@@ -2,8 +2,11 @@
namespace App\EventPaymentModules\Modules; namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule; use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -108,6 +111,41 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null); return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null);
} }
/**
* Erstattet wird auf das Konto, von dem der Beitrag kam -- und genau das steht seit dem
* Zahlungsimport in den Teilnehmer-Optionen.
*
* Gelesen wird ausschließlich aus `payment_options`, nicht aus dem Kennzeichen `refund_data`:
* Das ist ein abgeleitetes Merkmal für Listen und Abfragen. Zwei Quellen für dieselbe Wahrheit
* driften früher oder später auseinander, und die falsche gewänne dann eine Auszahlung.
*
* Eine IBAN, die die Prüfziffer nicht besteht, wird nicht gemeldet: Sie würde ungeprüft
* übernommen und das Geld ginge im Zweifel an eine fremde Person. Lieber wie bisher nachfragen.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
$response = new GetRefundDataResponse();
$options = $request->participant->payment_options ?? [];
$iban = Iban::normalize((string) ($options[self::OPTION_PAYER_IBAN] ?? ''));
$owner = trim((string) ($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? ''));
if ($iban === '' || $owner === '' || !Iban::isValid($iban)) {
return $response;
}
$response->source = RefundAccountSource::Known;
$response->accountOwner = $owner;
$response->accountIban = $iban;
$paidOn = $request->participant->last_payment_date?->format('d.m.Y');
$response->sourceNote = $paidOn === null
? 'Zahlungseingang'
: 'Zahlungseingang vom ' . $paidOn;
return $response;
}
/** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */ /** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */
public function isRelevantTransaction(BankTransaction $transaction): bool public function isRelevantTransaction(BankTransaction $transaction): bool
{ {
@@ -2,8 +2,11 @@
namespace App\EventPaymentModules\Modules; namespace App\EventPaymentModules\Modules;
use App\Enumerations\RefundAccountSource;
use App\EventPaymentModules\AbstractEventPaymentModule; use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -57,6 +60,25 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
return $response; return $response;
} }
/**
* Bar gezahlt heißt: Es gab nie ein Konto, von dem der Beitrag kam.
*
* Damit greift die sonst geltende Kontrolle „zurück nur auf das Ursprungskonto" nicht -- die Frage
* danach wäre für den Teili sinnlos und die Erklärung, es sei dasselbe Konto, schlicht unwahr. An
* ihre Stelle tritt die Erklärung, dass das angegebene Konto auf seinen Namen läuft.
*
* Zurückgezahlt wird trotzdem per Überweisung: Der Kontoauszug belegt die Zahlung, während eine
* Barauszahlung an einer Unterschrift hinge und die Barkasse berührte. Rechtlich spricht nichts
* dagegen -- eine Regel „bar rein, bar raus" gibt es nicht.
*/
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
{
$response = new GetRefundDataResponse();
$response->source = RefundAccountSource::None;
return $response;
}
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{ {
return sprintf( return sprintf(
@@ -2,8 +2,10 @@
namespace App\Mail\ParticipantRefundMails; namespace App\Mail\ParticipantRefundMails;
use App\Enumerations\RefundAccountSource;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
@@ -45,6 +47,17 @@ class RefundReleasedMail extends Mailable
'reasonNote' => $this->refund->reason_note, 'reasonNote' => $this->refund->reason_note,
// Absolute URL: der Link muss aus jedem Postfach heraus funktionieren. // Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
'link' => url('/rueckerstattung/' . $this->refund->token), 'link' => url('/rueckerstattung/' . $this->refund->token),
// Steht das Konto schon fest (aus dem Zahlungseingang), fragt die Mail nicht nach der
// Bankverbindung, sondern nur noch nach der Entscheidung auszahlen/spenden.
//
// Maskiert, aus demselben Grund wie auf der Seite: Diese Mail liegt in einem Postfach.
'knownAccountOwner' => $this->refund->account_owner,
'knownAccountIban' => $this->refund->account_iban === null
? null
: Iban::mask($this->refund->account_iban),
// Ohne Ursprungskonto (Barzahlung) wäre der Hinweis „nur auf das Konto, von dem gezahlt
// wurde" falsch -- dort ist stattdessen ein Konto auf den eigenen Namen gefragt.
'hasOriginAccount' => $this->participant->refundData()->source !== RefundAccountSource::None,
], ],
); );
} }
+19
View File
@@ -8,6 +8,8 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission; use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType; use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission; use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\DTO\GetRefundDataRequest;
use App\EventPaymentModules\DTO\GetRefundDataResponse;
use App\EventPaymentModules\DTO\RegistrationRenderContext; use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
@@ -238,4 +240,21 @@ class EventParticipant extends InstancedModel
return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration(), $context)); return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration(), $context));
} }
/**
* Auf welches Konto wäre zu erstatten -- und woher kommt es?
*
* Einmal hier statt viermal verstreut: Die Freigabe, die Teilnehmerliste, die öffentliche
* Erstattungsseite und der Beleg stellen dieselbe Frage und müssen dieselbe Antwort bekommen.
* Fehlt die Zahlungsart, gilt die strengere Vorgabe des Moduls-Standards (Ursprungskonto).
*/
public function refundData(): GetRefundDataResponse
{
$module = $this->paymentModule();
if ($module === null) {
return new GetRefundDataResponse();
}
return $module->getRefundData(new GetRefundDataRequest($this, $this->paymentConfiguration()));
}
} }
@@ -9,6 +9,7 @@ use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Repositories\ParticipantRefundRepository; use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -79,6 +80,7 @@ class EventParticipantResource extends JsonResource
// Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder // Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
// der letzte abgebrochen wurde. // der letzte abgebrochen wurde.
'refund' => $this->refund($request), 'refund' => $this->refund($request),
'refundData' => $this->refundData(),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age, 'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000', 'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000', 'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
@@ -124,4 +126,28 @@ class EventParticipantResource extends JsonResource
return $refund?->toResource()->toArray($request); return $refund?->toResource()->toArray($request);
} }
/**
* Das Konto, auf das erstattet würde -- sofern die Zahlungsart es kennt.
*
* Unmaskiert: Diese Resource speist die Teilnehmerliste der Aktionsleitung, die IBANs ohnehin
* sehen und eingeben darf. Auf der öffentlichen Erstattungsseite geht dieselbe Angabe maskiert
* hinaus, dort besorgt das der RefundPageController.
*
* @return array<string, mixed>
*/
private function refundData(): array
{
$refundData = $this->resource->refundData();
return [
// `available` heißt für das Frontend: Das Konto steht fest, es wird nicht mehr erfragt.
'available' => $refundData->hasAccount(),
'accountOwner' => $refundData->accountOwner,
'accountIban' => $refundData->accountIban === null
? null
: Iban::format($refundData->accountIban),
'source' => $refundData->sourceNote,
];
}
} }
+35
View File
@@ -57,6 +57,41 @@ final class Iban
return trim(chunk_split(self::normalize($iban), 4, ' ')); return trim(chunk_split(self::normalize($iban), 4, ' '));
} }
/**
* Nur Land, Prüfziffer und die letzten vier Stellen -- der Rest wird zu Punkten.
*
* Für Seiten, die über einen Token aus einer Mail erreichbar sind: Die eigene Bankverbindung
* erkennt man daran wieder, wer den Token aus einem fremden Postfach fischt, liest sie aber
* nicht mit.
*/
public static function mask(string $iban): string
{
$iban = self::normalize($iban);
// Zu kurz zum Maskieren wäre auch zu kurz zum Erkennen -- dann lieber alles verdecken.
$masked = strlen($iban) <= 8
? str_repeat('•', max(strlen($iban), 1))
: substr($iban, 0, 4) . str_repeat('•', strlen($iban) - 8) . substr($iban, -4);
return self::group($masked);
}
/**
* In Vierergruppen, zeichen- statt byteweise.
*
* `chunk_split()` zählt Bytes und würde das drei Byte lange „•" mitten durchschneiden -- heraus
* käme Zeichensalat.
*
* @param string $value
*/
private static function group(string $value): string
{
return implode(' ', array_map(
static fn (array $chunk): string => implode('', $chunk),
array_chunk(mb_str_split($value), 4),
));
}
/** /**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen * Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1. * (A = 10 Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
@@ -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 @endif
</table> </table>
<p> @if($knownAccountIban !== null)
Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den {{--
folgenden Link ein: Das Konto steht bereits fest: Es ist das, von dem der Beitrag kam, übernommen aus dem
</p> 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;"> <p style="padding: 10px 12px; border: 1px solid #dddddd; background-color: #f9f9f9;">
<strong>Wichtig:</strong> Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag {{$knownAccountOwner}}<br />
gezahlt wurde. Wurde er von einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils &ndash;, <span style="font-family: monospace; letter-spacing: 1px;">{{$knownAccountIban}}</span>
gib bitte dieses an. </p>
</p>
<p> <p style="font-size: 12px; color: #555;">
Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das Konto nicht zu deiner
Bankverbindung von dir; die Auswahl findest du hinter demselben Link. Zahlung, wende dich bitte an {{$eventEmail}}.
</p> </p>
<p> <p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen oder spenden</a> Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann verbleibt er beim Verband
</p> 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;"> <p style="font-size: 12px; color: #555;">
Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br /> Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br />
+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'));
}
}