Dev 4.8.1 #16

Merged
th.guenther merged 6 commits from dev-4.8.1 into main 2026-09-07 14:18:56 +02:00
26 changed files with 927 additions and 104 deletions
Showing only changes of commit b9795f08f0 - Show all commits
@@ -386,7 +386,9 @@ function saveParticipant() {
Bankverbindung des Teilis Bankverbindung des Teilis
</small> </small>
<small v-else-if="props.participant.refund.status === 'accepted'"> <small v-else-if="props.participant.refund.status === 'accepted'">
bestätigt am {{ props.participant.refund.acceptedAt }} bestätigt am {{ props.participant.refund.acceptedAt }}<template
v-if="props.participant.refund.donation"
> &ndash; gespendet, keine Auszahlung</template>
</small> </small>
<!-- Was beim Verband geblieben ist und warum. --> <!-- Was beim Verband geblieben ist und warum. -->
@@ -458,6 +458,8 @@ async function execRefund() {
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
// Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen. // Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
retentionReason: hasRetention.value ? refundForm.retentionReason : '', retentionReason: hasRetention.value ? refundForm.retentionReason : '',
retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '', retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '',
@@ -772,8 +774,8 @@ function mailToGroup(groupKey) {
</template> </template>
<!-- <!--
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn:
sofort eingereicht. Er bekommt den Beleg trotzdem. die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem.
--> -->
<div class="refund-field"> <div class="refund-field">
<label class="refund-choice"> <label class="refund-choice">
@@ -784,8 +786,17 @@ function mailToGroup(groupKey) {
<input type="radio" value="management" v-model="refundForm.captureMode" /> <input type="radio" value="management" v-model="refundForm.captureMode" />
Bankverbindung liegt mir vor Bankverbindung liegt mir vor
</label> </label>
<label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" />
Teilnehmer*in spendet den Betrag
</label>
</div> </div>
<p v-if="refundForm.captureMode === 'donation'" class="refund-hint">
Der Betrag wird nicht ausgezahlt, sondern als Spende gebucht. Die Erstattung wird sofort
eingereicht; der Teili erhält den Beleg per E-Mail.
</p>
<template v-if="refundForm.captureMode === 'management'"> <template v-if="refundForm.captureMode === 'management'">
<div class="refund-field"> <div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label> <label for="refund_account_owner">Kontoinhaber*in</label>
@@ -819,6 +830,7 @@ function mailToGroup(groupKey) {
> >
<template v-if="refundSaving">Wird gespeichert</template> <template v-if="refundSaving">Wird gespeichert</template>
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template> <template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
<template v-else-if="refundForm.captureMode === 'donation'">Als Spende einreichen</template>
<template v-else>Erstattung freigeben</template> <template v-else>Erstattung freigeben</template>
</button> </button>
</Modal> </Modal>
@@ -74,6 +74,15 @@ class AcceptRefundCommand
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.'; $response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
} }
// 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 ($owner === '') { if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.'; $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
} }
@@ -83,6 +92,7 @@ class AcceptRefundCommand
} elseif (!Iban::isValid($iban)) { } elseif (!Iban::isValid($iban)) {
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.'; $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
} }
}
if ($response->errorTypes !== []) { if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.'; $response->message = 'Bitte prüfe deine Angaben.';
@@ -108,14 +118,21 @@ class AcceptRefundCommand
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das // Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten. // Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) { $document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
$refund->account_owner = $owner; // Bei einer Spende bleiben die Kontofelder leer -- und zwar `null` und nicht Leerstring: Der
$refund->account_iban = $iban; // 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->captured_by = $this->request->capturedBy;
$refund->status = ParticipantRefund::STATUS_ACCEPTED; $refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now(); $refund->accepted_at = now();
$refund->save(); $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); $invoice = $this->createInvoice($refund, $costUnit, $document);
@@ -132,7 +149,9 @@ class AcceptRefundCommand
$this->notify($refund, $document); $this->notify($refund, $document);
$response->success = true; $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; return $response;
} }
@@ -177,7 +196,9 @@ class AcceptRefundCommand
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0, totalAmount: $refund->amount?->getAmount() ?? 0.0,
receiptFile: $this->storeReceipt($costUnit, $document), 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, userId: $participant->user_id,
contactEmail: $participant->email_1, contactEmail: $participant->email_1,
contactPhone: $participant->phone_1, contactPhone: $participant->phone_1,
@@ -247,8 +268,15 @@ class AcceptRefundCommand
{ {
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro'; $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( return Str::limit(sprintf(
'Rückerstattung Teilnahmebeitrag %s %s', '%s %s %s',
$subject,
$refund->event->name, $refund->event->name,
$refund->reasonLabel() $refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid); ), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
@@ -10,8 +10,28 @@ class AcceptRefundRequest
public readonly ?ParticipantRefund $refund, public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner, public readonly string $accountOwner,
public readonly string $accountIban, 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, 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. * 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. */ /** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND'; 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 ParticipantRefund $refund;
private EventParticipant $participant; private EventParticipant $participant;
@@ -148,12 +154,54 @@ class CreateRefundDocumentCommand
private function declarationText(): string 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) !== '' // Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
? (string) $text // gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
: 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig ' return $this->pageText(
. 'zurückerstattet bekomme.'; 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), 'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
'retention_note' => $this->retentionNote(), 'retention_note' => $this->retentionNote(),
'intro_text' => $this->introText(),
'declaration_text' => $this->declarationText(), 'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(), 'capture_note' => $this->captureNote(),
@@ -272,8 +321,17 @@ class CreateRefundDocumentCommand
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())]; $rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
} }
// 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[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))]; $rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
}
$html = ''; $html = '';
foreach ($rows as [$key, $value]) { foreach ($rows as [$key, $value]) {
@@ -8,6 +8,14 @@ class CreateRefundDocumentRequest
{ {
public function __construct( public function __construct(
public readonly ParticipantRefund $refund, 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(), 'released_at' => now(),
]); ]);
if ($this->request->hasBankDetails()) { if ($this->request->submitsDirectly()) {
$this->submitDirectly($refund); $this->submitDirectly($refund);
} }
return $refund; return $refund;
}); });
if (!$this->request->hasBankDetails()) { if (!$this->request->submitsDirectly()) {
$this->notify($refund); $this->notify($refund);
} }
$response->success = true; $response->success = true;
$response->refund = $refund->fresh(); $response->refund = $refund->fresh();
$response->message = $this->request->hasBankDetails() $response->message = match (true) {
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.' $this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.'; $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; return $response;
} }
@@ -99,6 +101,7 @@ class ReleaseRefundCommand
refund: $refund, refund: $refund,
accountOwner: (string) $this->request->accountOwner, accountOwner: (string) $this->request->accountOwner,
accountIban: (string) $this->request->accountIban, 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` // Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus. // fest, und der Beleg weist es aus.
capturedBy: currentUser()?->id, capturedBy: currentUser()?->id,
@@ -181,21 +184,29 @@ class ReleaseRefundCommand
*/ */
private function rejectBankDetails(): ?string 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. // 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.'; return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
} }
if (!$this->request->hasBankDetails()) { if (!$this->request->submitsDirectly()) {
return null; 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.'; 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 // Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen -- auch die Spende braucht eine, sie
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht. // 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) { if ($this->participant->event->cost_unit_id === null) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.'; 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 $retentionReason = null,
public readonly ?string $retentionReasonNote = 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); 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. * Der Betrag, der beim Verband bleibt.
* *
@@ -22,6 +22,8 @@ class AcceptRefundController extends CommonController
accountOwner: (string) $request->input('accountOwner'), accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'), accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'), declarationAccepted: $request->boolean('declarationAccepted'),
donation: $request->boolean('donation'),
accountDeclarationAccepted: $request->boolean('accountDeclarationAccepted'),
); );
$response = new AcceptRefundCommand($acceptRequest)->execute(); $response = new AcceptRefundCommand($acceptRequest)->execute();
@@ -18,7 +18,11 @@ class RefundDocumentController extends CommonController
abort(403, 'Zugriff verweigert.'); 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) { if (!$documentResponse->success) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.'); abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
@@ -53,6 +53,8 @@ class RefundPageController extends CommonController
return array_merge($common, [ return array_merge($common, [
'state' => 'accepted', 'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'), '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. // Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
retentionReason: Text::nullIfBlank($request->input('retentionReason')), retentionReason: Text::nullIfBlank($request->input('retentionReason')),
retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')), 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(); $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.'], '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_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'], '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.'], '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, reason: String,
reasonNote: String, reasonNote: String,
acceptedAt: String, acceptedAt: String,
donation: Boolean,
}) })
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden. // Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state) const state = ref(props.state)
const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false}) // Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
const errors = reactive({accountOwner: '', accountIban: '', declaration: ''}) // 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) const saving = ref(false)
/** /**
@@ -48,12 +68,41 @@ const accountComplete = computed(
() => form.accountOwner.trim() !== '' && form.accountIban.trim() !== '' () => 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() { function validate() {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.' Object.keys(errors).forEach((key) => (errors[key] = ''))
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.' 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() { async function submit() {
@@ -61,14 +110,21 @@ async function submit() {
saving.value = true saving.value = true
try { // Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', { const body = isDonation.value
method: 'POST', ? {donation: true, declarationAccepted: form.declarationAccepted}
body: { : {
donation: false,
accountOwner: form.accountOwner, accountOwner: form.accountOwner,
accountIban: form.accountIban, accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted, declarationAccepted: form.declarationAccepted,
}, accountDeclarationAccepted: form.accountDeclarationAccepted,
}
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body,
}) })
if (!response) { if (!response) {
@@ -88,6 +144,7 @@ async function submit() {
} }
toast.success(response.message) toast.success(response.message)
donated.value = isDonation.value
state.value = 'accepted' state.value = 'accepted'
} finally { } finally {
saving.value = false saving.value = false
@@ -128,7 +185,12 @@ async function submit() {
</table> </table>
<template v-if="state === 'accepted'"> <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>. 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 Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }} an sie: {{ props.eventEmail }}
@@ -141,9 +203,98 @@ async function submit() {
dich bitte an die Aktionsleitung: {{ props.eventEmail }} dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p> </p>
<h3>Auf welches Konto sollen wir überweisen?</h3>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<!--
Erste Frage: Wer spendet, braucht kein Konto anzugeben. Deshalb steht sie
vor allem anderen.
-->
<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-donation-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_DONATION"
belongs-to="refund-donation-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button
v-if="form.declarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ 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"> <div class="field">
<label for="account-owner">Kontoinhaber*in</label> <label for="account-owner">Kontoinhaber*in</label>
<input <input
@@ -163,9 +314,9 @@ async function submit() {
</div> </div>
<!-- <!--
Die Erklärung, die anschließend auf dem Eigenbeleg steht. Sie muss hier Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
gelesen und angekreuzt werden -- sonst schriebe der Beleg dem Teili eine müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
Zusicherung zu, die er nie abgegeben hat. dem Teili Zusicherungen zu, die er nie abgegeben hat.
--> -->
<template v-if="accountComplete"> <template v-if="accountComplete">
<div class="declaration"> <div class="declaration">
@@ -181,10 +332,23 @@ async function submit() {
</div> </div>
<ErrorText :message="errors.declaration" /> <ErrorText :message="errors.declaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der Knopf <div class="declaration">
unter dem Finger. --> <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 <button
v-if="form.declarationAccepted" v-if="form.declarationAccepted && form.accountDeclarationAccepted"
class="button" class="button"
type="submit" type="submit"
:disabled="saving" :disabled="saving"
@@ -192,6 +356,8 @@ async function submit() {
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }} {{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button> </button>
</template> </template>
</template>
</template>
</form> </form>
</template> </template>
</template> </template>
@@ -235,6 +401,34 @@ h3 {
padding: 6px 0; 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 { .field {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -31,7 +31,9 @@ class RefundAcceptedMail extends Mailable
{ {
return new Envelope( return new Envelope(
subject: sprintf( subject: sprintf(
'Deine Angaben zur Rückerstattung für %s', $this->refund->isDonation()
? 'Deine Spende statt Rückerstattung für %s'
: 'Deine Angaben zur Rückerstattung für %s',
$this->participant->event()->first()->name $this->participant->event()->first()->name
), ),
); );
@@ -54,6 +56,9 @@ class RefundAcceptedMail extends Mailable
'accountIban' => Iban::format((string) $this->refund->account_iban), 'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null, 'hasDocument' => $this->pdfContent !== null,
'invoiceNumber' => $invoice?->invoice_number, 'invoiceNumber' => $invoice?->invoice_number,
// Wurde gespendet, gibt es keine Bankverbindung und keine Überweisung, die angekündigt
// werden könnte. Steht in der Abrechnung, die hier ohnehin gelesen wird.
'donation' => (bool) $invoice?->donation,
// Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist. // Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist.
'hasRetention' => $this->refund->hasRetention(), 'hasRetention' => $this->refund->hasRetention(),
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro', 'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
+12
View File
@@ -117,6 +117,18 @@ class ParticipantRefund extends InstancedModel
return $this->captured_by !== null; return $this->captured_by !== null;
} }
/**
* Ob auf die Auszahlung verzichtet und der Betrag gespendet wurde.
*
* Steht in der Abrechnung und nicht am Vorgang -- wie der Auszahlungsstand, aus demselben Grund: eine
* zweite Spalte könnte davon abweichen. Vor dem Einreichen gibt es keine Abrechnung und nichts zu
* entscheiden, dann ist die Antwort `false`.
*/
public function isDonation(): bool
{
return (bool) $this->invoice()->first()?->donation;
}
public function isPending(): bool public function isPending(): bool
{ {
return $this->status === self::STATUS_PENDING; return $this->status === self::STATUS_PENDING;
@@ -40,6 +40,9 @@ class ParticipantRefundResource extends JsonResource
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'), 'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand. // Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number, 'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
// Ob überhaupt ausgezahlt wird: Bei einer Spende bleibt der Betrag beim Verband. Steht wie
// die Belegnummer in der Abrechnung.
'donation' => $this->resource->isDonation(),
]; ];
} }
} }
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Die beiden Erklärungen, die auf der Erstattungsseite neben der bestehenden stehen.
*
* `…_ACCOUNT` gilt nur im Auszahlungsweg: Zurückgezahlt wird ausschließlich auf das Konto, von dem der
* Beitrag kam -- sonst ließe sich über eine Erstattung Geld auf ein fremdes Konto umleiten, und die
* Zahlung wäre ihrer Herkunft nicht mehr zuzuordnen.
*
* `…_DONATION` tritt an die Stelle beider Sätze, wenn gespendet wird: Dann gibt es kein Konto, und die
* Person erklärt statt der Kontoangabe den Verzicht.
*
* Wie bei {@see 2026_09_04_140010_add_participant_refund_confirmation_text.php} liegen sie in
* `page_texts` und nicht in der Dokumentvorlage, damit Seite und Beleg denselben Wortlaut zeigen.
* Geschrieben wird nur, was fehlt -- eine bereits angepasste Fassung bleibt unangetastet.
*/
return new class extends Migration {
/** @var array<string, string> */
private const array TEXTS = [
'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT' => 'Ich bestätige, dass das angegebene Konto dasselbe '
. 'ist, von dem der Teilnahmebeitrag gezahlt wurde.',
'CONFIRMATION_PARTICIPANT_REFUND_DONATION' => '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.',
];
public function up(): void
{
foreach (self::TEXTS as $name => $content) {
if (DB::table('page_texts')->where('name', $name)->first() !== null) {
continue;
}
DB::table('page_texts')->insert([
'name' => $name,
'content' => $content,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
DB::table('page_texts')->whereIn('name', array_keys(self::TEXTS))->delete();
}
};
@@ -16,8 +16,12 @@
-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht, -- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht,
-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil). -- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil).
-- --
-- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili -- Der Erklärungssatz kommt aus den Seitentexten CONFIRMATION_PARTICIPANT_REFUND(_ACCOUNT bzw. _DONATION),
-- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text. -- denselben, die der Teili vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als
-- fester Text.
--
-- Ebenso der Einleitungssatz: Er lautet bei einer Spende anders als bei einer Auszahlung, und {if:…} kennt
-- keine Verneinung, mit der sich der eine gegen den anderen tauschen ließe.
DELETE FROM document_templates WHERE document_type = 'participant_refund'; DELETE FROM document_templates WHERE document_type = 'participant_refund';
@@ -129,7 +133,7 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
<td>{unregistered_at}</td> <td>{unregistered_at}</td>
</tr>{/if:unregistered_at} </tr>{/if:unregistered_at}
</table>', 80, 1, NOW(), NOW()), </table>', 80, 1, NOW(), NOW()),
('participant_refund', 'body', '<div class="intro-text">Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die R&uuml;ckerstattung wie folgt:</div> ('participant_refund', 'body', '<div class="intro-text">{intro_text}</div>
{details_table} {details_table}
@@ -2,7 +2,25 @@
<html> <html>
<body> <body>
<h1>Hallo {{$name}}!</h1> <h1>Hallo {{$name}}!</h1>
@if ($donation)
@if ($capturedByManagement) @if ($capturedByManagement)
<p>
die Aktionsleitung hat vermerkt, dass du auf die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" verzichtest und den Betrag spendest.
<strong>Du musst nichts weiter tun.</strong>
</p>
<p>
<strong>Bitte prüfe die unten stehenden Angaben.</strong> Stimmt etwas nicht, melde dich
bitte umgehend bei der Aktionsleitung.
</p>
@else
<p>
vielen Dank &ndash; du hast auf die Rückerstattung deines Teilnahmebeitrags zur Veranstaltung
"{{$eventTitle}}" verzichtet und den Betrag gespendet.
<strong>Du musst nichts weiter tun.</strong>
</p>
@endif
@elseif ($capturedByManagement)
<p> <p>
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die
@@ -34,6 +52,12 @@
<td style="padding: 4px 0;">{{$invoiceNumber}}</td> <td style="padding: 4px 0;">{{$invoiceNumber}}</td>
</tr> </tr>
@endif @endif
@if ($donation)
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Auszahlung:</td>
<td style="padding: 4px 0;">als Spende beim Verband verblieben</td>
</tr>
@else
<tr> <tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td> <td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
<td style="padding: 4px 0;">{{$accountOwner}}</td> <td style="padding: 4px 0;">{{$accountOwner}}</td>
@@ -42,6 +66,7 @@
<td style="padding: 4px 16px 4px 0; color: #555;">IBAN:</td> <td style="padding: 4px 16px 4px 0; color: #555;">IBAN:</td>
<td style="padding: 4px 0;">{{$accountIban}}</td> <td style="padding: 4px 0;">{{$accountIban}}</td>
</tr> </tr>
@endif
</table> </table>
@if ($hasRetention) @if ($hasRetention)
@@ -65,10 +90,16 @@
</p> </p>
@endif @endif
@if ($donation)
<p>
Vielen Dank, dass du uns den Betrag überlässt &ndash; er kommt unserer Arbeit zugute.
</p>
@else
<p> <p>
Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen. Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung. Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
</p> </p>
@endif
<p> <p>
@include('emails.subparts.disclaimer') @include('emails.subparts.disclaimer')
@@ -29,8 +29,19 @@
folgenden Link ein: folgenden Link ein:
</p> </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 &ndash; etwa dem eines Elternteils &ndash;,
gib bitte dieses an.
</p>
<p> <p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen</a> 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> </p>
<p style="font-size: 12px; color: #555;"> <p style="font-size: 12px; color: #555;">
@@ -39,9 +50,9 @@
</p> </p>
<p> <p>
Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag Solange uns deine Bankverbindung nicht vorliegt und du dich auch nicht für die Spende entschieden
selbst steht fest und lässt sich über den Link nicht ändern &ndash; wenn du dazu Fragen hast, wende hast, können wir den Vorgang nicht abschließen. Der Betrag selbst steht fest und lässt sich über den
dich bitte an die Aktionsleitung. Link nicht ändern &ndash; wenn du dazu Fragen hast, wende dich bitte an die Aktionsleitung.
</p> </p>
<p> <p>
+127
View File
@@ -232,12 +232,26 @@ class ParticipantRefundTest extends TestCase
string $owner = 'Mika Muster', string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051', string $iban = 'DE02120300000000202051',
bool $declarationAccepted = true, bool $declarationAccepted = true,
bool $accountDeclarationAccepted = true,
) { ) {
return new AcceptRefundCommand(new AcceptRefundRequest( return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund, refund: $refund,
accountOwner: $owner, accountOwner: $owner,
accountIban: $iban, accountIban: $iban,
declarationAccepted: $declarationAccepted, declarationAccepted: $declarationAccepted,
accountDeclarationAccepted: $accountDeclarationAccepted,
))->execute();
}
/** Der zweite Weg: Der Teili verzichtet auf die Auszahlung und spendet -- ohne Bankverbindung. */
private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true)
{
return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: $declarationAccepted,
donation: true,
))->execute(); ))->execute();
} }
@@ -459,6 +473,105 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
} }
public function test_accept_is_rejected_without_the_account_declaration(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Erstattet wird nur auf das Konto, von dem der Beitrag kam. Fehlt die Bestätigung, ließe sich
// über eine Erstattung Geld auf ein fremdes Konto umleiten.
$response = $this->accept($refund, accountDeclarationAccepted: false);
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountDeclaration', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
$this->assertNull($refund->fresh()->account_iban);
}
public function test_the_account_declaration_cannot_be_skipped_over_http(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true,
])
->assertOk()
->assertJsonPath('status', 'error')
->assertJsonStructure(['error_types' => ['accountDeclaration']]);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
/*
|--------------------------------------------------------------------------
| Spenden statt auszahlen
|--------------------------------------------------------------------------
*/
public function test_a_donation_is_accepted_without_bank_details(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->acceptAsDonation($refund);
$this->assertTrue($response->success);
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
$this->assertTrue($refund->fresh()->isDonation());
}
public function test_a_donation_still_needs_the_declaration(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Der Verzicht ist die Erklärung, die anschließend auf dem Beleg steht -- ohne sie nichts.
$response = $this->acceptAsDonation($refund, declarationAccepted: false);
$this->assertFalse($response->success);
$this->assertArrayHasKey('declaration', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
public function test_a_donation_ignores_bank_details_sent_along(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Wer im Formular erst ein Konto eintippt und dann doch spendet, soll es nicht hinterlassen.
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
donation: true,
))->execute();
$this->assertTrue($response->success);
$this->assertNull($refund->fresh()->account_owner);
$this->assertNull($refund->fresh()->account_iban);
}
public function test_a_donation_over_http_needs_no_iban(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'donation' => true,
'declarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success');
$this->assertTrue($refund->fresh()->isDonation());
}
public function test_the_page_shows_a_finished_donation_as_donated(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->acceptAsDonation($refund);
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'accepted')->where('donation', true));
}
public function test_the_public_page_serves_the_declaration_text_name(): void public function test_the_public_page_serves_the_declaration_text_name(): void
{ {
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -473,6 +586,19 @@ class ParticipantRefundTest extends TestCase
->assertSee('Ich versichere', false); ->assertSee('Ich versichere', false);
} }
public function test_the_public_page_serves_the_new_declaration_texts(): void
{
// Beide Erklärungen stehen auf der Seite, die ohne Login erreichbar ist -- käme eine davon
// nicht durch, stünde dort eine leere Checkbox, die sich trotzdem ankreuzen ließe.
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT)
->assertOk()
->assertSee('dass das angegebene Konto dasselbe ist', false);
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DONATION_DECLARATION_TEXT)
->assertOk()
->assertSee('verzichte auf die Auszahlung', false);
}
public function test_accept_requires_an_account_owner(): void public function test_accept_requires_an_account_owner(): void
{ {
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -716,6 +842,7 @@ class ParticipantRefundTest extends TestCase
'accountOwner' => 'Mika Muster', 'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51', 'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true, 'declarationAccepted' => true,
'accountDeclarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success'); ])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban); $this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
+88
View File
@@ -412,6 +412,94 @@ class RefundDirectCaptureTest extends TestCase
$this->assertNotNull(ParticipantRefund::first()->invoice_id); $this->assertNotNull(ParticipantRefund::first()->invoice_id);
} }
/*
|--------------------------------------------------------------------------
| Der Teili spendet -- die Aktionsleitung nimmt es auf
|--------------------------------------------------------------------------
*/
public function test_a_donation_is_submitted_right_away_without_bank_details(): void
{
$participant = $this->makeParticipant();
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertTrue($response->success);
$this->assertStringContainsString('Spende', $response->message);
$refund = ParticipantRefund::first();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertTrue($refund->isDonation());
// Wer sie aufgenommen hat, wird auch hier festgehalten -- der Teili hat nichts angekreuzt.
$this->assertSame($this->management->id, $refund->captured_by);
$this->assertTrue((bool) Invoice::first()->donation);
}
public function test_a_donation_with_bank_details_is_refused(): void
{
$participant = $this->makeParticipant();
// Beides zusammen ist widersprüchlich: Lieber nachfragen, als eines stillschweigend zu verwerfen.
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Bankverbindung', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_donation_without_a_cost_unit_is_refused(): void
{
// Gebucht wird sie trotzdem -- ohne Kostenstelle gibt es nichts, worauf.
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Kostenstelle', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_release_over_http_submits_a_donation(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => '',
'accountIban' => '',
'donation' => true,
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED)
->assertJsonPath('refund.donation', true);
$this->assertTrue((bool) Invoice::first()->donation);
}
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
{ {
$participant = $this->makeParticipant(); $participant = $this->makeParticipant();
+59 -5
View File
@@ -98,7 +98,8 @@ class RefundDocumentTest extends TestCase
DocumentTemplate::create([ DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY, 'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>' 'content' => '<p>{intro_text}</p>{details_table}'
. '<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>'
. '<p>{account_owner} / {account_iban}</p>' . '<p>{account_owner} / {account_iban}</p>'
. '<p>{paid_amount} / {invoice_number}</p>' . '<p>{paid_amount} / {invoice_number}</p>'
. '<p>{declaration_text}</p>', . '<p>{declaration_text}</p>',
@@ -195,15 +196,19 @@ class RefundDocumentTest extends TestCase
], $attributes)); ], $attributes));
} }
private function document(ParticipantRefund $refund) private function document(ParticipantRefund $refund, bool $donation = false)
{ {
return new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); return new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
)->execute();
} }
/** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */ /** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */
private function html(ParticipantRefund $refund): string private function html(ParticipantRefund $refund, bool $donation = false): string
{ {
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); $command = new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
);
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command); $number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number); $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
@@ -380,6 +385,55 @@ class RefundDocumentTest extends TestCase
$this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund())); $this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund()));
} }
public function test_the_payout_receipt_carries_the_account_declaration_too(): void
{
// Beide Sätze wurden angekreuzt, beide gehören auf den Beleg: Die Kontoerklärung ist der Grund,
// warum die Auszahlung auf genau dieses Konto zulässig ist.
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html);
$this->assertStringContainsString('dass das angegebene Konto dasselbe ist', $html);
}
/*
|--------------------------------------------------------------------------
| Der Beleg über eine Spende
|--------------------------------------------------------------------------
*/
public function test_a_donation_receipt_carries_the_waiver_instead(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringContainsString('verzichte auf die Auszahlung', $html);
// Die Erklärungen des Auszahlungswegs haben hier nichts zu suchen -- es gibt kein Konto.
$this->assertStringNotContainsString('dass das angegebene Konto dasselbe ist', $html);
}
public function test_a_donation_receipt_shows_no_bank_details(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringNotContainsString('IBAN', $html);
$this->assertStringContainsString('Auf die Auszahlung wird verzichtet', $html);
}
public function test_the_intro_sentence_follows_the_chosen_way(): void
{
$refund = $this->makeRefund();
$this->assertStringContainsString('bitte um die Rückerstattung', $this->html($refund));
$this->assertStringContainsString('spende ihn an den Verband', $this->html($refund, donation: true));
}
public function test_a_donation_receipt_is_a_pdf_as_well(): void
{
$response = $this->document($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertTrue($response->success);
$this->assertStringStartsWith('%PDF', $response->pdfContent);
}
public function test_free_text_reason_replaces_the_catalog_text(): void public function test_free_text_reason_replaces_the_catalog_text(): void
{ {
$html = $this->html($this->makeRefund([ $html = $this->html($this->makeRefund([
+81 -2
View File
@@ -221,6 +221,7 @@ class RefundInvoiceTest extends TestCase
float $amount = 220.0, float $amount = 220.0,
string $reason = RefundReason::SICKNESS, string $reason = RefundReason::SICKNESS,
?string $retentionReason = RetentionReason::CANCELLATION_FEE, ?string $retentionReason = RetentionReason::CANCELLATION_FEE,
bool $donation = false,
): ParticipantRefund { ): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent()); $participant ??= $this->makeParticipant($this->makeEvent());
@@ -231,11 +232,14 @@ class RefundInvoiceTest extends TestCase
retentionReason: $retentionReason, retentionReason: $retentionReason,
))->execute()->refund; ))->execute()->refund;
// Wer spendet, gibt keine Bankverbindung an -- der Server verlangt sie dann auch nicht.
new AcceptRefundCommand(new AcceptRefundRequest( new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund, refund: $refund,
accountOwner: 'Mika Muster', accountOwner: $donation ? '' : 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: $donation ? '' : 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
donation: $donation,
accountDeclarationAccepted: !$donation,
))->execute(); ))->execute();
return $refund->fresh(); return $refund->fresh();
@@ -262,6 +266,80 @@ class RefundInvoiceTest extends TestCase
$this->assertFalse((bool) $invoice->donation); $this->assertFalse((bool) $invoice->donation);
} }
/*
|--------------------------------------------------------------------------
| Spenden statt auszahlen
|--------------------------------------------------------------------------
*/
public function test_a_donated_refund_becomes_a_donated_invoice(): void
{
$this->runRefund(donation: true);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertTrue((bool) $invoice->donation);
// Derselbe Weg wie sonst: Nummernkreis, Status und Typ der Abrechnung ändern sich nicht.
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
}
public function test_a_donation_carries_no_bank_details(): void
{
$refund = $this->runRefund(donation: true);
// Weder am Vorgang noch an der Abrechnung -- und `null`, nicht Leerstring: Der SEPA-Export
// unterscheidet daran, ob es etwas auszuzahlen gibt.
$this->assertNull($refund->account_owner);
$this->assertNull($refund->account_iban);
$this->assertNull(Invoice::first()->contact_bank_owner);
$this->assertNull(Invoice::first()->contact_bank_iban);
}
public function test_the_refund_reads_the_donation_from_its_invoice(): void
{
// Am Vorgang steht sie nicht: Die Abrechnung führt sie, damit beide nicht auseinanderlaufen.
$this->assertTrue($this->runRefund(donation: true)->isDonation());
}
public function test_a_payout_is_no_donation(): void
{
$this->assertFalse($this->runRefund()->isDonation());
}
public function test_a_donation_still_gets_a_receipt(): void
{
$this->runRefund(donation: true);
$invoice = Invoice::first();
$this->assertNotNull($invoice->document_filename);
Storage::disk('local')->assertExists($invoice->document_filename);
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
}
public function test_a_donation_settles_the_amount_paid_like_a_payout(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant, donation: true);
// Was einbehalten wurde, bleibt einbehaltener Teilnahmebeitrag; der erstattungsfähige Teil ist
// ab jetzt eine Spende und wird über die Abrechnung geführt, nicht mehr über den Beitrag.
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_the_notice_of_a_donation_names_it(): void
{
$this->runRefund(donation: true);
// Die Schatzmeisterei sieht die Abrechnung ohne den Vorgang dahinter -- warum keine
// Bankverbindung dabei ist, steht nur hier.
$this->assertStringContainsString('Spende statt Rückerstattung', Invoice::first()->comment);
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', Invoice::first()->comment);
}
public function test_contact_details_come_from_the_participant(): void public function test_contact_details_come_from_the_participant(): void
{ {
$this->runRefund(); $this->runRefund();
@@ -384,6 +462,7 @@ class RefundInvoiceTest extends TestCase
accountOwner: 'Mika Muster', accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute(); ))->execute();
$this->assertFalse($response->success); $this->assertFalse($response->success);
+1
View File
@@ -222,6 +222,7 @@ class RefundRetentionTest extends TestCase
accountOwner: 'Mika Muster', accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute(); ))->execute();
} }