+ Der Betrag wird nicht ausgezahlt, sondern als Spende gebucht. Die Erstattung wird sofort
+ eingereicht; der Teili erhält den Beleg per E-Mail.
+
+
@@ -819,6 +830,7 @@ function mailToGroup(groupKey) {
>
Wird gespeichert…
Erstattung einreichen
+ Als Spende einreichen
Erstattung freigeben
diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
index 4e72de9..ef20412 100644
--- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
@@ -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);
diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
index f2daa72..a0ea0f5 100644
--- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
@@ -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.
*
diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
index bcb7a4c..6820fda 100644
--- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
@@ -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.'
+ ) . '
' . $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]) {
diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
index 5c03c8c..6a670fc 100644
--- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
@@ -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,
) {
}
}
diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
index 4e95f4f..1cf8d8a 100644
--- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
@@ -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.';
}
diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
index 337793e..ad76a59 100644
--- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
@@ -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.
*
diff --git a/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php b/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
index 47f123c..e352510 100644
--- a/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
+++ b/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
@@ -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();
diff --git a/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php b/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
index 82f7b2c..a31db28 100644
--- a/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
+++ b/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
@@ -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.');
diff --git a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
index 0f91794..031e5c9 100644
--- a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
+++ b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
@@ -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(),
]);
}
diff --git a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
index 090294c..ba827c2 100644
--- a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
+++ b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
@@ -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();
diff --git a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
index 59907f2..f7fca5b 100644
--- a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
+++ b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
@@ -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.
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.'],
],
],
diff --git a/app/Domains/ParticipantRefund/Views/RefundPage.vue b/app/Domains/ParticipantRefund/Views/RefundPage.vue
index 1f143f1..2c2abbb 100644
--- a/app/Domains/ParticipantRefund/Views/RefundPage.vue
+++ b/app/Domains/ParticipantRefund/Views/RefundPage.vue
@@ -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() {
-
+
+ Du hast den Betrag gespendet (am {{ props.acceptedAt }})
+ – vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
+ bitte an die Aktionsleitung: {{ props.eventEmail }}
+
+
Deine Angaben liegen uns vor (seit {{ props.acceptedAt }}).
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 }}
-
Auf welches Konto sollen wir überweisen?
-
@@ -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;
}
diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
index e5fe6d9..ea81bde 100644
--- a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
+++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
@@ -31,7 +31,9 @@ class RefundAcceptedMail extends Mailable
{
return new Envelope(
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
),
);
@@ -54,6 +56,9 @@ class RefundAcceptedMail extends Mailable
'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null,
'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.
'hasRetention' => $this->refund->hasRetention(),
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php
index afbe8e5..a9118ac 100644
--- a/app/Models/ParticipantRefund.php
+++ b/app/Models/ParticipantRefund.php
@@ -117,6 +117,18 @@ class ParticipantRefund extends InstancedModel
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
{
return $this->status === self::STATUS_PENDING;
diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php
index b732628..8bfd936 100644
--- a/app/Resources/ParticipantRefundResource.php
+++ b/app/Resources/ParticipantRefundResource.php
@@ -40,6 +40,9 @@ class ParticipantRefundResource extends JsonResource
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
'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(),
];
}
}
diff --git a/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php b/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php
new file mode 100644
index 0000000..7239f8c
--- /dev/null
+++ b/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php
@@ -0,0 +1,50 @@
+ */
+ 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();
+ }
+};
diff --git a/database/seed-scripts/participant-refund-template.sql b/database/seed-scripts/participant-refund-template.sql
index 1e0a4e1..2023f8b 100644
--- a/database/seed-scripts/participant-refund-template.sql
+++ b/database/seed-scripts/participant-refund-template.sql
@@ -16,8 +16,12 @@
-- 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).
--
--- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili
--- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text.
+-- Der Erklärungssatz kommt aus den Seitentexten CONFIRMATION_PARTICIPANT_REFUND(_ACCOUNT bzw. _DONATION),
+-- 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';
@@ -129,7 +133,7 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
+ die Aktionsleitung hat vermerkt, dass du auf die Rückerstattung deines Teilnahmebeitrags zur
+ Veranstaltung "{{$eventTitle}}" verzichtest und den Betrag spendest.
+ Du musst nichts weiter tun.
+
+
+ Bitte prüfe die unten stehenden Angaben. Stimmt etwas nicht, melde dich
+ bitte umgehend bei der Aktionsleitung.
+
+ @else
+
+ vielen Dank – du hast auf die Rückerstattung deines Teilnahmebeitrags zur Veranstaltung
+ "{{$eventTitle}}" verzichtet und den Betrag gespendet.
+ Du musst nichts weiter tun.
+
+ @endif
+@elseif ($capturedByManagement)
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" erfasst. Du musst nichts weiter tun: Die
@@ -34,14 +52,21 @@
- 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.
-
+@if ($donation)
+
+ Vielen Dank, dass du uns den Betrag überlässt – er kommt unserer Arbeit zugute.
+
+@else
+
+ 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.
+
+@endif
@include('emails.subparts.disclaimer')
diff --git a/resources/views/emails/events/refund_released.blade.php b/resources/views/emails/events/refund_released.blade.php
index 7abcf04..806296f 100644
--- a/resources/views/emails/events/refund_released.blade.php
+++ b/resources/views/emails/events/refund_released.blade.php
@@ -29,8 +29,19 @@
folgenden Link ein:
+
+ Wichtig: Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
+ gezahlt wurde. Wurde er von einem anderen Konto überwiesen – etwa dem eines Elternteils –,
+ gib bitte dieses an.
+
+
- Bankverbindung eintragen
+ Du kannst den Betrag stattdessen auch spenden. Dann brauchen wir keine
+ Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
+
- Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag
- selbst steht fest und lässt sich über den Link nicht ändern – wenn du dazu Fragen hast, wende
- dich bitte an die Aktionsleitung.
+ Solange uns deine Bankverbindung nicht vorliegt und du dich auch nicht für die Spende entschieden
+ hast, können wir den Vorgang nicht abschließen. Der Betrag selbst steht fest und lässt sich über den
+ Link nicht ändern – wenn du dazu Fragen hast, wende dich bitte an die Aktionsleitung.
diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php
index ccf4de3..a568d80 100644
--- a/tests/Feature/ParticipantRefundTest.php
+++ b/tests/Feature/ParticipantRefundTest.php
@@ -232,12 +232,26 @@ class ParticipantRefundTest extends TestCase
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
bool $declarationAccepted = true,
+ bool $accountDeclarationAccepted = true,
) {
return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: $owner,
accountIban: $iban,
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();
}
@@ -459,6 +473,105 @@ class ParticipantRefundTest extends TestCase
$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
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -473,6 +586,19 @@ class ParticipantRefundTest extends TestCase
->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
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -716,6 +842,7 @@ class ParticipantRefundTest extends TestCase
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true,
+ 'accountDeclarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
diff --git a/tests/Feature/RefundDirectCaptureTest.php b/tests/Feature/RefundDirectCaptureTest.php
index b086f48..8a9be14 100644
--- a/tests/Feature/RefundDirectCaptureTest.php
+++ b/tests/Feature/RefundDirectCaptureTest.php
@@ -412,6 +412,94 @@ class RefundDirectCaptureTest extends TestCase
$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
{
$participant = $this->makeParticipant();
diff --git a/tests/Feature/RefundDocumentTest.php b/tests/Feature/RefundDocumentTest.php
index f399337..5cfb32d 100644
--- a/tests/Feature/RefundDocumentTest.php
+++ b/tests/Feature/RefundDocumentTest.php
@@ -98,7 +98,8 @@ class RefundDocumentTest extends TestCase
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
- 'content' => '{details_table}