Besseres Handling Rückerstattungen

This commit is contained in:
2026-09-06 20:11:50 +02:00
parent e730d6db63
commit b9795f08f0
26 changed files with 927 additions and 104 deletions
@@ -74,14 +74,24 @@ class AcceptRefundCommand
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
}
if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
}
// Wer spendet, gibt kein Konto an -- alles Weitere betrifft nur die Auszahlung.
if (!$this->request->donation) {
// Erstattet wird ausschließlich auf das Konto, von dem der Beitrag kam. Ohne diese
// Bestätigung ließe sich über eine Erstattung Geld auf ein fremdes Konto umleiten.
if (!$this->request->accountDeclarationAccepted && $this->request->capturedBy === null) {
$response->errorTypes['accountDeclaration'] = 'Bitte bestätige, dass es das Konto ist, '
. 'von dem der Beitrag gezahlt wurde.';
}
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 ($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 ($response->errorTypes !== []) {
@@ -108,14 +118,21 @@ class AcceptRefundCommand
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
$refund->account_owner = $owner;
$refund->account_iban = $iban;
// Bei einer Spende bleiben die Kontofelder leer -- und zwar `null` und nicht Leerstring: Der
// SEPA-Export unterscheidet daran, ob es etwas auszuzahlen gibt.
$refund->account_owner = $this->request->donation ? null : $owner;
$refund->account_iban = $this->request->donation ? null : $iban;
$refund->captured_by = $this->request->capturedBy;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
// Die Spende wird hier ausdrücklich mitgegeben statt am Vorgang abgelesen: Die Abrechnung,
// die sie führt, entsteht erst weiter unten -- dieser Beleg ist ihr Anhang.
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest(
refund: $refund,
donation: $this->request->donation,
))->execute();
$invoice = $this->createInvoice($refund, $costUnit, $document);
@@ -132,7 +149,9 @@ class AcceptRefundCommand
$this->notify($refund, $document);
$response->success = true;
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
$response->message = $this->request->donation
? 'Vielen Dank für deine Spende.'
: 'Vielen Dank. Deine Angaben liegen uns vor.';
return $response;
}
@@ -177,7 +196,9 @@ class AcceptRefundCommand
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0,
receiptFile: $this->storeReceipt($costUnit, $document),
isDonation: false,
// Hier landet die Entscheidung des Teilis, und nur hier: Die Abrechnung führt sie, der
// Vorgang liest sie über ParticipantRefund::isDonation() zurück.
isDonation: $this->request->donation,
userId: $participant->user_id,
contactEmail: $participant->email_1,
contactPhone: $participant->phone_1,
@@ -247,8 +268,15 @@ class AcceptRefundCommand
{
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro';
// Die Schatzmeisterei sieht die Abrechnung ohne den Vorgang dahinter. Dass nichts ausgezahlt
// wird, steht zwar im Spendenkennzeichen -- warum keine Bankverbindung dabei ist, aber nur hier.
$subject = $this->request->donation
? 'Spende statt Rückerstattung Teilnahmebeitrag'
: 'Rückerstattung Teilnahmebeitrag';
return Str::limit(sprintf(
'Rückerstattung Teilnahmebeitrag %s %s',
'%s %s %s',
$subject,
$refund->event->name,
$refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
@@ -10,8 +10,28 @@ class AcceptRefundRequest
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
public readonly string $accountIban,
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
/**
* Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts.
*
* Im Spendenweg ist es die Verzichtserklärung, im Auszahlungsweg die Versicherung über den
* gezahlten Beitrag -- in beiden Fällen die Erklärung, die anschließend auf dem Beleg steht.
*/
public readonly bool $declarationAccepted = false,
/**
* Ob auf die Auszahlung verzichtet und der Betrag gespendet wird.
*
* Wird nicht am Vorgang gespeichert: Die Entscheidung landet in der Abrechnung
* (`invoices.donation`), von wo {@see ParticipantRefund::isDonation()} sie zurückliest. Dieses
* Feld ist der Weg dorthin.
*/
public readonly bool $donation = false,
/**
* Nur im Auszahlungsweg: die Bestätigung, dass es das Konto der Ursprungszahlung ist.
*
* Getrennt von der Haupterklärung, weil sie im Spendenweg gegenstandslos ist -- dort gibt es
* kein Konto.
*/
public readonly bool $accountDeclarationAccepted = false,
/**
* Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag.
*
@@ -27,6 +27,12 @@ class CreateRefundDocumentCommand
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
private ParticipantRefund $refund;
private EventParticipant $participant;
@@ -148,12 +154,54 @@ class CreateRefundDocumentCommand
private function declarationText(): string
{
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
if ($this->request->donation) {
return $this->pageText(
self::DONATION_DECLARATION_TEXT,
'Ich verzichte auf die Auszahlung des genannten Betrags und spende ihn an den Verband. '
. 'Mir ist bewusst, dass dieser Verzicht nicht rückgängig gemacht werden kann.'
);
}
return trim((string) $text) !== ''
? (string) $text
: 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.';
// 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.
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.'
);
}
/**
* Ein Seitentext mit Rückfallwert: Fehlt die Zeile in der Datenbank, soll der Beleg trotzdem
* entstehen. Ohne den Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im
* Deckblatt-Code der Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
*/
private function pageText(string $name, string $fallback): string
{
$text = PageText::where('name', $name)->first()?->content;
return trim((string) $text) !== '' ? (string) $text : $fallback;
}
/**
* Der Einleitungssatz des Belegs, passend zum gewählten Weg.
*
* Als Platzhalter und nicht fest in der Vorlage, weil er sich zwischen Auszahlung und Spende
* unterscheidet -- `{if:…}` kennt keine Verneinung, mit der eine Vorlage den einen Satz gegen den
* anderen tauschen könnte. Ältere, bereits installierte Vorlagen tragen den festen Satz weiter; dass
* gespendet wurde, steht dort in der Angabentabelle und in der Erklärung.
*/
private function introText(): string
{
return $this->request->donation
? 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen. Auf die Auszahlung '
. 'des erstattungsfähigen Betrags verzichte ich und spende ihn an den Verband:'
: 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die '
. 'Rückerstattung wie folgt:';
}
/**
@@ -222,6 +270,7 @@ class CreateRefundDocumentCommand
'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
'retention_note' => $this->retentionNote(),
'intro_text' => $this->introText(),
'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(),
@@ -272,8 +321,17 @@ class CreateRefundDocumentCommand
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
}
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
// Bei einer Spende gibt es keine Bankverbindung. Statt zwei leerer Zeilen steht dort, warum --
// der Beleg wandert in die Buchhaltung, und "keine IBAN" allein sähe nach einer Lücke aus.
if ($this->request->donation) {
$rows[] = [
'Auszahlung',
'Auf die Auszahlung wird verzichtet; der Betrag verbleibt als Spende beim Verband.',
];
} else {
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
}
$html = '';
foreach ($rows as [$key, $value]) {
@@ -8,6 +8,14 @@ class CreateRefundDocumentRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
/**
* Ob der Betrag gespendet statt ausgezahlt wird.
*
* Ausdrücklich und nicht über {@see ParticipantRefund::isDonation()}: Beim Einreichen entsteht
* dieser Beleg vor der Abrechnung, die die Spende führt -- er ist ihr Anhang. Wer ihn später
* erneut zieht, liest sie dort und gibt sie hier weiter.
*/
public readonly bool $donation = false,
) {
}
}
@@ -67,22 +67,24 @@ class ReleaseRefundCommand
'released_at' => now(),
]);
if ($this->request->hasBankDetails()) {
if ($this->request->submitsDirectly()) {
$this->submitDirectly($refund);
}
return $refund;
});
if (!$this->request->hasBankDetails()) {
if (!$this->request->submitsDirectly()) {
$this->notify($refund);
}
$response->success = true;
$response->refund = $refund->fresh();
$response->message = $this->request->hasBankDetails()
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
$response->message = match (true) {
$this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
$this->request->hasBankDetails() => 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
default => 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.',
};
return $response;
}
@@ -99,6 +101,7 @@ class ReleaseRefundCommand
refund: $refund,
accountOwner: (string) $this->request->accountOwner,
accountIban: (string) $this->request->accountIban,
donation: $this->request->donation,
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus.
capturedBy: currentUser()?->id,
@@ -181,21 +184,29 @@ class ReleaseRefundCommand
*/
private function rejectBankDetails(): ?string
{
// Eine Spende wird nicht ausgezahlt. Kämen beide Angaben zusammen, wäre unklar, was gilt --
// lieber nachfragen als das eine stillschweigend gegen das andere entscheiden.
if ($this->request->donation && (filled($this->request->accountOwner) || filled($this->request->accountIban))) {
return 'Eine Spende braucht keine Bankverbindung.';
}
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
if (!$this->request->donation
&& filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
}
if (!$this->request->hasBankDetails()) {
if (!$this->request->submitsDirectly()) {
return null;
}
if (!Iban::isValid((string) $this->request->accountIban)) {
if ($this->request->hasBankDetails() && !Iban::isValid((string) $this->request->accountIban)) {
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.';
}
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen -- auch die Spende braucht eine, sie
// wird ja gebucht. Hier abfangen und nicht erst in der Transaktion, damit die Aktionsleitung eine
// verständliche Meldung sieht.
if ($this->participant->event->cost_unit_id === null) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
}
@@ -28,6 +28,13 @@ class ReleaseRefundRequest
*/
public readonly ?string $retentionReason = null,
public readonly ?string $retentionReasonNote = null,
/**
* Der Teili spendet den Betrag, statt ihn ausgezahlt zu bekommen.
*
* Dann braucht es keine Bankverbindung, und die Erstattung wird -- wie beim vorliegenden Konto --
* sofort eingereicht. Gespeichert wird die Entscheidung in der Abrechnung, nicht am Vorgang.
*/
public readonly bool $donation = false,
) {
}
@@ -37,6 +44,12 @@ class ReleaseRefundRequest
return filled($this->accountOwner) && filled($this->accountIban);
}
/** Ob sofort eingereicht wird -- entweder liegt die Bankverbindung vor, oder es wird gespendet. */
public function submitsDirectly(): bool
{
return $this->hasBankDetails() || $this->donation;
}
/**
* Der Betrag, der beim Verband bleibt.
*
@@ -22,6 +22,8 @@ class AcceptRefundController extends CommonController
accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'),
donation: $request->boolean('donation'),
accountDeclarationAccepted: $request->boolean('accountDeclarationAccepted'),
);
$response = new AcceptRefundCommand($acceptRequest)->execute();
@@ -18,7 +18,11 @@ class RefundDocumentController extends CommonController
abort(403, 'Zugriff verweigert.');
}
$documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
$documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest(
refund: $refund,
// Beim Nachdruck steht die Abrechnung längst -- sie führt die Spende.
donation: $refund->isDonation(),
))->execute();
if (!$documentResponse->success) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
@@ -53,6 +53,8 @@ class RefundPageController extends CommonController
return array_merge($common, [
'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'),
// Wurde gespendet, darf der Abschlusstext keine Überweisung ankündigen, die nicht kommt.
'donation' => $refund->isDonation(),
]);
}
@@ -31,6 +31,9 @@ class ReleaseRefundController extends CommonController
// Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
retentionReason: Text::nullIfBlank($request->input('retentionReason')),
retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')),
// Der Teili hat der Aktionsleitung gesagt, dass er spenden möchte -- dann entfällt die
// Bankverbindung und die Erstattung wird sofort als Spende eingereicht.
donation: $request->boolean('donation'),
);
$response = new ReleaseRefundCommand($refundRequest)->execute();
@@ -67,7 +67,8 @@ final class ParticipantRefundTokens
'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'],
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
'declaration_text' => ['description' => 'Die Erklärungen, die die teilnehmende Person bestätigt hat (gepflegt als Seitentexte CONFIRMATION_PARTICIPANT_REFUND und CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT, bei einer Spende CONFIRMATION_PARTICIPANT_REFUND_DONATION)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.<br /><br />Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag gezahlt wurde.'],
'intro_text' => ['description' => 'Einleitungssatz des Belegs — bei einer Spende der Verzicht statt der Bitte um Rückerstattung', 'sample' => 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die Rückerstattung wie folgt:'],
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
],
],
@@ -27,13 +27,33 @@ const props = defineProps({
reason: String,
reasonNote: String,
acceptedAt: String,
donation: Boolean,
})
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false})
const errors = reactive({accountOwner: '', accountIban: '', declaration: ''})
// Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
// Neuladen holt ihn vom Controller.
const donated = ref(props.donation === true)
/**
* Der Weg durch die Seite, nach dem Muster der Auslagenabrechnung (refund-data.vue): erst die
* Entscheidung, dann die Angaben dazu. Nichts wird gefragt, was für den gewählten Weg keine Rolle spielt.
*
* decision -- '' (noch nichts gewählt) | 'donation' (spenden) | 'payout' (auszahlen lassen)
* sameAccount -- nur bei 'payout': ob es das Konto der Ursprungszahlung ist
*/
const decision = ref('')
const sameAccount = ref(null)
const form = reactive({
accountOwner: '',
accountIban: '',
declarationAccepted: false,
accountDeclarationAccepted: false,
})
const errors = reactive({accountOwner: '', accountIban: '', declaration: '', accountDeclaration: ''})
const saving = ref(false)
/**
@@ -48,12 +68,41 @@ const accountComplete = computed(
() => form.accountOwner.trim() !== '' && form.accountIban.trim() !== ''
)
const isDonation = computed(() => decision.value === 'donation')
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
* Ohne das Zurücksetzen stünde eine Erklärung als bestätigt da, die in diesem Weg gar nicht gezeigt
* wurde -- wer erst die Auszahlung ankreuzt und dann zur Spende wechselt, hätte den Verzicht nie gelesen.
*/
function choose(next) {
decision.value = next
sameAccount.value = null
form.declarationAccepted = false
form.accountDeclarationAccepted = false
Object.keys(errors).forEach((key) => (errors[key] = ''))
}
/** Zurück zur ersten Frage -- aus der Sackgasse „anderes Konto" führt der Weg zur Spende. */
function reset() {
choose('')
}
function validate() {
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.'
Object.keys(errors).forEach((key) => (errors[key] = ''))
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
return !errors.accountOwner && !errors.accountIban && !errors.declaration
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.'
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
}
return Object.values(errors).every((message) => message === '')
}
async function submit() {
@@ -61,14 +110,21 @@ async function submit() {
saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted}
: {
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', {
method: 'POST',
body: {
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
},
body,
})
if (!response) {
@@ -88,6 +144,7 @@ async function submit() {
}
toast.success(response.message)
donated.value = isDonation.value
state.value = 'accepted'
} finally {
saving.value = false
@@ -128,7 +185,12 @@ async function submit() {
</table>
<template v-if="state === 'accepted'">
<p class="hint">
<p v-if="donated" class="hint">
Du hast den Betrag gespendet<span v-if="props.acceptedAt"> (am {{ props.acceptedAt }})</span>
&ndash; vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<p v-else class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
@@ -141,57 +203,161 @@ async function submit() {
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<h3>Auf welches Konto sollen wir überweisen?</h3>
<form @submit.prevent="submit">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
<!--
Die Erklärung, die anschließend auf dem Eigenbeleg steht. Sie muss hier
gelesen und angekreuzt werden -- sonst schriebe der Beleg dem Teili eine
Zusicherung zu, die er nie abgegeben hat.
Erste Frage: Wer spendet, braucht kein Konto anzugeben. Deshalb steht sie
vor allem anderen.
-->
<template v-if="accountComplete">
<h3>Möchtest du den Betrag stattdessen spenden?</h3>
<p class="choice-hint">
Spendest du, verbleibt der Betrag beim Verband und kommt unserer Arbeit
zugute. Wir brauchen dann keine Bankverbindung von dir.
</p>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: decision === 'donation'}"
@click="choose('donation')"
>Ja, spenden</button>
<button
type="button"
class="choice"
:class="{active: decision === 'payout'}"
@click="choose('payout')"
>Nein, bitte erstatten</button>
</div>
<!-- Spendenweg: nur der Verzicht, keine Kontoangaben. -->
<template v-if="decision === 'donation'">
<div class="declaration">
<input
id="refund-declaration"
id="refund-donation-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
text-name="CONFIRMATION_PARTICIPANT_REFUND_DONATION"
belongs-to="refund-donation-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der Knopf
unter dem Finger. -->
<button
v-if="form.declarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
{{ saving ? 'Wird gespeichert…' : 'Betrag spenden' }}
</button>
</template>
<!--
Auszahlungsweg: Erstattet wird ausschließlich auf das Konto, von dem der
Beitrag kam. Das wird gefragt, bevor überhaupt eine IBAN eingegeben wird --
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>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<template v-if="sameAccount === false">
<p class="hint">
So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
<template v-else-if="sameAccount === true">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
<!--
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.
-->
<template v-if="accountComplete">
<div class="declaration">
<input
id="refund-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<div class="declaration">
<input
id="refund-account-declaration"
v-model="form.accountDeclarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT"
belongs-to="refund-account-declaration"
/>
</div>
<ErrorText :message="errors.accountDeclaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der
Knopf unter dem Finger. -->
<button
v-if="form.declarationAccepted && form.accountDeclarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</template>
</template>
</template>
</form>
</template>
</template>
@@ -235,6 +401,34 @@ h3 {
padding: 6px 0;
}
.choice-hint {
margin-bottom: 12px;
font-size: 0.9rem;
color: #4b5563;
}
.choices {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.choice {
flex: 1;
padding: 10px 12px;
border: 1px solid #d8dde6;
border-radius: 0;
background: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.choice.active {
border-color: #1a4799;
background: #eef2fa;
font-weight: bold;
}
.field {
margin-bottom: 16px;
}