Automatischer Zahlungsparser
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user