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