diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue index 00333b0..567e610 100644 --- a/app/Domains/Event/Views/Partials/ParticipantsList.vue +++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue @@ -10,6 +10,7 @@ import AmountInput from "../../../../Views/Components/AmountInput.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue"; import ErrorText from "../../../../Views/Components/ErrorText.vue"; +import IbanInput from "../../../../Views/Components/IbanInput.vue"; const props = defineProps({ data: { @@ -47,10 +48,11 @@ const openCancelDialog = ref(false); const openPartialPaymentDialogSwitch = ref(false); const openRefundDialogSwitch = ref(false); -// Der Erstattungsdialog. Betrag und Grund werden hier gesetzt; die Bankverbindung erfasst der Teili -// selbst über den Link, den die Freigabe ihm schickt. -const refundForm = reactive({amount: '', reason: '', reasonNote: ''}); -const refundErrors = reactive({amount: '', reason: '', reasonNote: ''}); +// Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über +// den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits +// vor -- dann wird die Erstattung sofort eingereicht. +const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''}); +const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''}); const refundReasons = ref([]); const refundSaving = ref(false); @@ -317,9 +319,12 @@ async function openRefundDialog(participant) { refundForm.amount = participant.amountPaid?.short ?? ''; refundForm.reason = ''; refundForm.reasonNote = ''; - refundErrors.amount = ''; - refundErrors.reason = ''; - refundErrors.reasonNote = ''; + // Vorgabe ist der übliche Weg über den Teili. + refundForm.captureMode = 'participant'; + refundForm.accountOwner = ''; + refundForm.accountIban = ''; + + Object.keys(refundErrors).forEach(key => refundErrors[key] = ''); if (refundReasons.value.length === 0) { const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'}); @@ -333,9 +338,7 @@ function validateRefund() { const amount = Number((refundForm.amount ?? '').replace(',', '.')); const paid = Number(showParticipant.value?.amountPaidValue ?? 0); - refundErrors.amount = ''; - refundErrors.reason = ''; - refundErrors.reasonNote = ''; + Object.keys(refundErrors).forEach(key => refundErrors[key] = ''); if (!refundForm.amount || !(amount > 0)) { refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.'; @@ -349,7 +352,19 @@ function validateRefund() { refundErrors.reasonNote = 'Bitte erläutere den Grund.'; } - return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote; + // Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen. + // Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge. + if (refundForm.captureMode === 'management') { + if (!refundForm.accountOwner.trim()) { + refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.'; + } + + if (!refundForm.accountIban.trim()) { + refundErrors.accountIban = 'Bitte gib die IBAN des Kontos ein.'; + } + } + + return Object.values(refundErrors).every(message => !message); } async function execRefund() { @@ -366,13 +381,22 @@ async function execRefund() { amount: refundForm.amount, reason: refundForm.reason, reasonNote: refundForm.reasonNote, + // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. + accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', + accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', }, }); if (data?.status === 'success') { toast.success(data.message); // Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm. + // Beim Direktweg steht dort sofort "Erstattet" samt Abrechnungsnummer. showParticipant.value.refund = data.refund; + // Der gezahlte Beitrag wird beim Einreichen auf 0 gesetzt -- sonst zeigte die Zeile weiter + // den alten Stand, bis jemand neu lädt. + if (data.refund?.status === 'accepted') { + showParticipant.value.amountPaidValue = 0; + } openRefundDialogSwitch.value = false; } else { toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.'); @@ -603,8 +627,7 @@ function mailToGroup(groupKey) { >

{{ showParticipant?.fullname }} hat - {{ showParticipant?.amountPaid?.readable }} gezahlt. Nach der Freigabe erhält - der Teili eine E-Mail und trägt seine Bankverbindung selbst ein. + {{ showParticipant?.amountPaid?.readable }} gezahlt.

@@ -632,8 +655,49 @@ function mailToGroup(groupKey) {
+ +
+ + +
+ + + @@ -668,10 +732,32 @@ function mailToGroup(groupKey) { } .refund-field select, -.refund-field textarea { +.refund-field textarea, +.refund-field .form-input { width: 100%; } +.refund-choice { + display: block; + margin-bottom: 6px; + font-size: 0.9rem; + color: #1a1a1a; + cursor: pointer; +} + +.refund-choice input { + margin-right: 6px; +} + +.refund-hint { + margin-bottom: 14px; + padding: 8px 10px; + border-left: 3px solid #f5c400; + background-color: #fffef5; + font-size: 0.85rem; + color: #4b5563; +} + .participants-table { width: 95%; margin: 20px auto; diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php index 31825b7..977a832 100644 --- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php +++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php @@ -67,7 +67,10 @@ class AcceptRefundCommand // Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg // als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf // dem PDF eine Zusicherung, die niemand abgegeben hat. - if (!$this->request->declarationAccepted) { + // + // Nimmt die Aktionsleitung die Angaben auf, kreuzt naturgemäß niemand etwas an. Nachvollziehbar + // bleibt es trotzdem: `captured_by` hält fest, wer sie aufgenommen hat, und der Beleg weist es aus. + if (!$this->request->declarationAccepted && $this->request->capturedBy === null) { $response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.'; } @@ -107,6 +110,7 @@ class AcceptRefundCommand $document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) { $refund->account_owner = $owner; $refund->account_iban = $iban; + $refund->captured_by = $this->request->capturedBy; $refund->status = ParticipantRefund::STATUS_ACCEPTED; $refund->accepted_at = now(); $refund->save(); diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php index 637e0ee..f2daa72 100644 --- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php +++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php @@ -12,6 +12,13 @@ class AcceptRefundRequest public readonly string $accountIban, /** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */ public readonly bool $declarationAccepted = false, + /** + * Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag. + * + * Dann kreuzt niemand die Erklärung an -- sie wird stellvertretend aufgenommen, und der Beleg + * weist genau das aus. + */ + public readonly ?int $capturedBy = null, ) { } } diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php index eaa41f4..65e61e5 100644 --- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php +++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php @@ -104,6 +104,28 @@ class CreateRefundDocumentCommand * 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. */ + /** + * Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat. + * + * Er nennt Name und Datum, weil in diesem Fall niemand die Erklärung darüber angekreuzt hat: Wer den + * Beleg prüft, soll erkennen, dass dort eine aufgenommene Angabe steht und keine Bestätigung des + * Teilis selbst. Beim gewöhnlichen Weg bleibt der Platzhalter leer und der Block fällt weg. + */ + private function captureNote(): string + { + if (!$this->refund->wasCapturedByManagement()) { + return ''; + } + + $name = $this->refund->capturedBy()->first()?->getOfficialName(); + + return sprintf( + 'Angaben aufgenommen durch %s am %s.', + trim((string) $name) !== '' ? $name : 'die Aktionsleitung', + $this->refund->accepted_at?->format('d.m.Y') ?? '' + ); + } + private function declarationText(): string { $text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content; @@ -178,6 +200,7 @@ class CreateRefundDocumentCommand 'account_iban' => $this->formatIban((string) $refund->account_iban), 'declaration_text' => $this->declarationText(), + 'capture_note' => $this->captureNote(), 'details_table' => $this->renderDetails(), ]; diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php index 83c5382..c39b64c 100644 --- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php +++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php @@ -2,13 +2,18 @@ namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund; +use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand; +use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest; use App\Enumerations\RefundReason; use App\Mail\ParticipantRefundMails\RefundReleasedMail; use App\Models\EventParticipant; use App\Models\ParticipantRefund; use App\Repositories\ParticipantRefundRepository; +use App\Support\Iban; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; +use RuntimeException; /** * Gibt die Erstattung eines Teilnahmebeitrags frei. @@ -40,28 +45,66 @@ class ReleaseRefundCommand return $response; } - $refund = ParticipantRefund::create([ - 'tenant' => $this->participant->tenant, - 'event_id' => $this->participant->event_id, - 'event_participant_id' => $this->participant->id, - 'token' => Str::random(32), - 'status' => ParticipantRefund::STATUS_PENDING, - 'amount' => $this->request->amount, - 'reason' => $this->request->reason, - 'reason_note' => $this->reasonNote(), - 'released_by' => auth()->id(), - 'released_at' => now(), - ]); + // Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die, + // soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion. + $refund = DB::transaction(function (): ParticipantRefund { + $refund = ParticipantRefund::create([ + 'tenant' => $this->participant->tenant, + 'event_id' => $this->participant->event_id, + 'event_participant_id' => $this->participant->id, + 'token' => Str::random(32), + 'status' => ParticipantRefund::STATUS_PENDING, + 'amount' => $this->request->amount, + 'reason' => $this->request->reason, + 'reason_note' => $this->reasonNote(), + 'released_by' => auth()->id(), + 'released_at' => now(), + ]); - $this->notify($refund); + if ($this->request->hasBankDetails()) { + $this->submitDirectly($refund); + } + + return $refund; + }); + + if (!$this->request->hasBankDetails()) { + $this->notify($refund); + } $response->success = true; - $response->refund = $refund; - $response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.'; + $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.'; return $response; } + /** + * Reicht die Erstattung sofort ein, ohne den Umweg über den Teili. + * + * Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen + * des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab. + */ + private function submitDirectly(ParticipantRefund $refund): void + { + $acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest( + refund: $refund, + accountOwner: (string) $this->request->accountOwner, + accountIban: (string) $this->request->accountIban, + // Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by` + // fest, und der Beleg weist es aus. + capturedBy: auth()->id(), + ))->execute(); + + if (!$acceptResponse->success) { + // Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen + // Vorgang vorfinden, der nirgends eingereicht ist. + throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.'); + } + } + /** * Alle Gründe, aus denen eine Freigabe nicht zulässig ist. * @@ -98,6 +141,34 @@ class ReleaseRefundCommand return 'Für diesen Grund ist eine Erläuterung erforderlich.'; } + return $this->rejectBankDetails(); + } + + /** + * Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt + * also keine zweite Gelegenheit, Angaben zu berichtigen. + */ + private function rejectBankDetails(): ?string + { + // Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili. + if (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()) { + return null; + } + + if (!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. + if ($this->participant->event->cost_unit_id === null) { + return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.'; + } + return null; } diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php index a1fbc8a..d6f0930 100644 --- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php +++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php @@ -12,6 +12,20 @@ class ReleaseRefundRequest public readonly Amount $amount, public readonly string $reason, public readonly ?string $reasonNote = null, + /** + * Die Bankverbindung, wenn sie der Aktionsleitung bereits vorliegt. + * + * Sind beide gesetzt, entfällt der Umweg über den Teili: die Erstattung wird sofort eingereicht. + * Bleiben sie leer, läuft der übliche Weg über den Bestätigungslink. + */ + public readonly ?string $accountOwner = null, + public readonly ?string $accountIban = null, ) { } + + /** Ob die Erstattung ohne Zutun des Teilis eingereicht werden kann. */ + public function hasBankDetails(): bool + { + return filled($this->accountOwner) && filled($this->accountIban); + } } diff --git a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php index 5b62771..a92bb02 100644 --- a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php +++ b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php @@ -25,6 +25,9 @@ class ReleaseRefundController extends CommonController amount: Amount::fromString((string) $request->input('amount'), 'Euro'), reason: (string) $request->input('reason'), reasonNote: Text::nullIfBlank($request->input('reasonNote')), + // Leer, wenn der Teili die Bankverbindung selbst eintragen soll. + accountOwner: Text::nullIfBlank($request->input('accountOwner')), + accountIban: Text::nullIfBlank($request->input('accountIban')), ); $response = new ReleaseRefundCommand($refundRequest)->execute(); diff --git a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php index cbd954c..5e90382 100644 --- a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php +++ b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php @@ -66,6 +66,7 @@ final class ParticipantRefundTokens '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.'], + 'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'], ], ], 'body' => [ diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php index 3b12655..a50ca50 100644 --- a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php +++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php @@ -54,6 +54,9 @@ class RefundAcceptedMail extends Mailable 'accountIban' => Iban::format((string) $this->refund->account_iban), 'hasDocument' => $this->pdfContent !== null, 'invoiceNumber' => $invoice?->invoice_number, + // Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts + // eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken. + 'capturedByManagement' => $this->refund->wasCapturedByManagement(), // Der Hinweis auf "Meine Abrechnungen" nur, wenn die Anmeldung an einem Konto hängt -- // die Seite filtert über die Nutzer-Verknüpfung und bliebe sonst leer. 'myInvoicesUrl' => $invoice?->user_id !== null ? url('/invoice/my-invoices/new') : null, diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php index 4084543..5a0948d 100644 --- a/app/Models/ParticipantRefund.php +++ b/app/Models/ParticipantRefund.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $reason_note * @property string|null $account_owner * @property string|null $account_iban + * @property int|null $captured_by * @property int|null $invoice_id * @property int|null $released_by * @property \Illuminate\Support\Carbon|null $released_at @@ -52,6 +53,7 @@ class ParticipantRefund extends InstancedModel 'reason_note', 'account_owner', 'account_iban', + 'captured_by', 'invoice_id', 'released_by', 'released_at', @@ -93,6 +95,20 @@ class ParticipantRefund extends InstancedModel return $this->belongsTo(Invoice::class); } + /** + * Wer die Bankverbindung aufgenommen hat -- leer, wenn der Teili sie selbst eingetragen hat. + */ + public function capturedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'captured_by'); + } + + /** Ob die Angaben von der Aktionsleitung stammen und nicht vom Teili selbst. */ + public function wasCapturedByManagement(): bool + { + return $this->captured_by !== null; + } + public function isPending(): bool { return $this->status === self::STATUS_PENDING; diff --git a/database/migrations/2026_09_06_140010_add_captured_by_to_participant_refunds.php b/database/migrations/2026_09_06_140010_add_captured_by_to_participant_refunds.php new file mode 100644 index 0000000..f6f2303 --- /dev/null +++ b/database/migrations/2026_09_06_140010_add_captured_by_to_participant_refunds.php @@ -0,0 +1,34 @@ +foreignId('captured_by')->nullable()->after('account_iban') + ->constrained('users', 'id')->nullOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('participant_refunds', function (Blueprint $table) { + $table->dropForeign(['captured_by']); + $table->dropColumn('captured_by'); + }); + } +}; diff --git a/database/seed-scripts/participant-refund-template.sql b/database/seed-scripts/participant-refund-template.sql index f400776..1e0a4e1 100644 --- a/database/seed-scripts/participant-refund-template.sql +++ b/database/seed-scripts/participant-refund-template.sql @@ -84,6 +84,9 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1 /* Die Versicherung des Teilis unter den Angaben */ .declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; } +/* Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat -- steht sonst nicht auf dem Beleg. */ +.capture-note { font-size: 8pt; color: #555; line-height: 1.55; margin-top: 3mm; } + /* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */ .edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()), ('participant_refund', 'header_sender_return', '
{sender_name}{if:sender_address_1} · {sender_address_1}{/if:sender_address_1} · {sender_postcode} {sender_city}
', 30, 1, NOW(), NOW()), @@ -130,5 +133,6 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1 {details_table} -
{declaration_text}
', 90, 1, NOW(), NOW()), +
{declaration_text}
+{if:capture_note}
{capture_note}
{/if:capture_note}', 90, 1, NOW(), NOW()), ('participant_refund', 'footer', '', 100, 1, NOW(), NOW()); diff --git a/resources/views/emails/events/refund_accepted.blade.php b/resources/views/emails/events/refund_accepted.blade.php index 26eb892..a14f487 100644 --- a/resources/views/emails/events/refund_accepted.blade.php +++ b/resources/views/emails/events/refund_accepted.blade.php @@ -2,11 +2,22 @@

Hallo {{$name}}!

-

- vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen - uns vor. Du musst nichts weiter tun: Die Erstattung ist bereits als Abrechnung - eingereicht und wird nun bearbeitet. -

+@if ($capturedByManagement) +

+ die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur + Veranstaltung "{{$eventTitle}}" erfasst. Du musst nichts weiter tun: Die + Erstattung ist bereits als Abrechnung eingereicht und wird nun bearbeitet. +

+

+ Bitte prüfe die unten stehenden Angaben – besonders die IBAN. +

+@else +

+ vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen + uns vor. Du musst nichts weiter tun: Die Erstattung ist bereits als Abrechnung + eingereicht und wird nun bearbeitet. +

+@endif diff --git a/storage/temp/Erstattungsbeleg-mit-vermerk.pdf b/storage/temp/Erstattungsbeleg-mit-vermerk.pdf new file mode 100644 index 0000000..56f7dc9 Binary files /dev/null and b/storage/temp/Erstattungsbeleg-mit-vermerk.pdf differ diff --git a/storage/temp/Erstattungsbeleg-ohne-vermerk.pdf b/storage/temp/Erstattungsbeleg-ohne-vermerk.pdf new file mode 100644 index 0000000..244b527 Binary files /dev/null and b/storage/temp/Erstattungsbeleg-ohne-vermerk.pdf differ diff --git a/tests/Feature/RefundDirectCaptureTest.php b/tests/Feature/RefundDirectCaptureTest.php new file mode 100644 index 0000000..63b153a --- /dev/null +++ b/tests/Feature/RefundDirectCaptureTest.php @@ -0,0 +1,409 @@ +tenant = Tenant::create([ + 'slug' => 'wm', + 'name' => 'Wilde Möhre', + 'address_1' => 'Musterweg 1', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'invoice_prefix' => 'WM', + 'is_active_local_group' => true, + 'has_active_instance' => true, + ]); + + app()->instance('tenant', $this->tenant); + + DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); + DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); + DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); + DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); + EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); + + foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { + UserRole::create(['slug' => $role, 'name' => $role]); + } + + $this->seedTemplate(); + + // Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang. + $this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN); + $this->actingAs($this->management); + + Storage::fake('local'); + Mail::fake(); + } + + private function seedTemplate(): void + { + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_LAYOUT, + 'content' => '
{block:body}
', + 'sort_order' => 10, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_BODY, + 'content' => '{details_table}

{declaration_text}

' + . '{if:capture_note}

{capture_note}

{/if:capture_note}', + 'sort_order' => 20, + ]); + } + + private function makeUser(string $firstname, string $lastname, string $role): User + { + return User::create([ + 'username' => strtolower($lastname) . '-' . uniqid() . '@example.com', + 'email' => strtolower($lastname) . '-' . uniqid() . '@example.com', + 'firstname' => $firstname, + 'lastname' => $lastname, + 'password' => bcrypt('secret'), + 'local_group' => $this->tenant->slug, + 'user_role_main' => $role, + 'user_role_local_group' => UserRole::USER_ROLE_USER, + 'active' => true, + ]); + } + + private function makeEvent(array $attributes = []): Event + { + $fee = EventParticipationFee::create([ + 'tenant' => $this->tenant->slug, + 'type' => 'participant', + 'name' => 'Sippe', + 'description' => null, + 'amount_standard' => 60.0, + 'amount_reduced' => null, + 'amount_solidarity' => null, + ]); + + $costUnit = CostUnit::create([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, + 'distance_allowance' => 0.25, + 'mail_on_new' => false, + 'allow_new' => true, + 'archived' => false, + ]); + + return Event::create(array_merge([ + 'cost_unit_id' => $costUnit->id, + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'identifier' => 'evt-' . uniqid(), + 'location' => 'Ort', + 'postal_code' => '00000', + 'email' => 'e@example.com', + 'start_date' => '2026-07-16', + 'end_date' => '2026-07-20', + 'early_bird_end' => '2026-06-20', + 'registration_final_end' => '2026-07-01', + 'early_bird_end_amount_increase' => 0, + 'account_owner' => 'Owner', + 'account_iban' => 'DE00', + 'participation_fee_type' => 'fixed', + 'participation_fee_1' => $fee->id, + 'pay_per_day' => true, + 'pay_direct' => false, + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => 'inclusive', + 'invoice_key' => 'WM-V-20260701', + ], $attributes)); + } + + private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant + { + $event ??= $this->makeEvent(); + $this->sequence++; + + return $event->participants()->create(array_merge([ + 'tenant' => $this->tenant->slug, + 'identifier' => 'p-' . uniqid(), + 'invoice_sequence' => $this->sequence, + 'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id, + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'participation_type' => 'participant', + 'fee_type' => 'standard', + 'sibling_reduction' => false, + 'local_group' => $this->tenant->slug, + 'birthday' => '2000-01-01', + 'address_1' => 'Beispielstraße 3', + 'postcode' => '11111', + 'city' => 'Beispielstadt', + 'email_1' => 'mika@example.com', + 'phone_1' => '0170 0000000', + 'arrival_date' => '2026-07-16', + 'departure_date' => '2026-07-20', + 'arrival_eating' => 1, + 'departure_eating' => 1, + 'amount' => 300.0, + 'amount_paid' => 300.0, + 'unregistered_at' => '2026-06-12', + 'payment_purpose' => 'Sommerlager', + 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, + 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, + ], $attributes)); + } + + /** Freigabe mit bereits bekannter Bankverbindung. */ + private function releaseWithBankDetails( + ?EventParticipant $participant = null, + string $owner = 'Mika Muster', + string $iban = 'DE02120300000000202051', + float $amount = 220.0, + ) { + return new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $participant ?? $this->makeParticipant(), + amount: new Amount($amount, 'Euro'), + reason: RefundReason::SICKNESS, + accountOwner: $owner, + accountIban: $iban, + ))->execute(); + } + + /* + |-------------------------------------------------------------------------- + | Der Vorgang ist sofort abgeschlossen + |-------------------------------------------------------------------------- + */ + + public function test_the_refund_is_submitted_right_away(): void + { + $participant = $this->makeParticipant(); + + $response = $this->releaseWithBankDetails($participant); + + $this->assertTrue($response->success); + $this->assertStringContainsString('eingereicht', $response->message); + + $refund = ParticipantRefund::first(); + $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status); + $this->assertSame('Mika Muster', $refund->account_owner); + $this->assertSame('DE02120300000000202051', $refund->account_iban); + $this->assertNotNull($refund->accepted_at); + } + + public function test_the_capturing_person_is_recorded(): void + { + $this->releaseWithBankDetails(); + + $refund = ParticipantRefund::first(); + + $this->assertSame($this->management->id, $refund->captured_by); + $this->assertTrue($refund->wasCapturedByManagement()); + } + + public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void + { + $participant = $this->makeParticipant(); + + $this->releaseWithBankDetails($participant); + + $invoice = Invoice::first(); + $this->assertNotNull($invoice); + $this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001); + $this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id); + + $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + } + + public function test_only_the_receipt_mail_goes_out(): void + { + $this->releaseWithBankDetails(); + + // Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos. + Mail::assertNotSent(RefundReleasedMail::class); + Mail::assertSent(RefundAcceptedMail::class); + } + + /* + |-------------------------------------------------------------------------- + | Der Beleg weist aus, wer die Angaben aufgenommen hat + |-------------------------------------------------------------------------- + */ + + public function test_the_receipt_names_who_captured_the_details(): void + { + $this->releaseWithBankDetails(); + + $html = $this->renderReceipt(ParticipantRefund::first()); + + // Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst. + $this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html); + $this->assertStringContainsString('Ich versichere', $html); + } + + public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void + { + // Gegenprobe: der gewöhnliche Weg bleibt unverändert. + $refund = new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $this->makeParticipant(), + amount: new Amount(220.0, 'Euro'), + reason: RefundReason::SICKNESS, + ))->execute()->refund; + + $refund->update([ + 'status' => ParticipantRefund::STATUS_ACCEPTED, + 'account_owner' => 'Mika Muster', + 'account_iban' => 'DE02120300000000202051', + 'accepted_at' => now(), + ]); + + $this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh())); + } + + private function renderReceipt(ParticipantRefund $refund): string + { + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); + $number = new \ReflectionMethod($command, 'documentNumber')->invoke($command); + $tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number); + + return new \App\Providers\DocumentTemplateRenderProvider( + DocumentTemplate::TYPE_PARTICIPANT_REFUND + )->render($tokens); + } + + /* + |-------------------------------------------------------------------------- + | Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen + |-------------------------------------------------------------------------- + */ + + public function test_an_invalid_iban_stops_everything(): void + { + // Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher. + $response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015'); + + $this->assertFalse($response->success); + $this->assertStringContainsString('IBAN', $response->message); + $this->assertSame(0, ParticipantRefund::count()); + $this->assertSame(0, Invoice::count()); + } + + public function test_half_filled_bank_details_are_refused(): void + { + $response = new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $this->makeParticipant(), + amount: new Amount(220.0, 'Euro'), + reason: RefundReason::SICKNESS, + accountOwner: 'Mika Muster', + ))->execute(); + + $this->assertFalse($response->success); + $this->assertSame(0, ParticipantRefund::count()); + } + + public function test_without_a_cost_unit_nothing_is_created(): void + { + $participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null])); + + $response = $this->releaseWithBankDetails($participant); + + $this->assertFalse($response->success); + $this->assertStringContainsString('Kostenstelle', $response->message); + $this->assertSame(0, ParticipantRefund::count()); + $this->assertSame(0, Invoice::count()); + // Der gezahlte Beitrag bleibt unangetastet. + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + } + + /* + |-------------------------------------------------------------------------- + | Über HTTP + |-------------------------------------------------------------------------- + */ + + public function test_release_over_http_submits_directly(): void + { + $participant = $this->makeParticipant(); + + $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ + 'amount' => '220,00', + 'reason' => RefundReason::SICKNESS, + 'accountOwner' => 'Mika Muster', + 'accountIban' => 'DE02 1203 0000 0000 2020 51', + ]) + ->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED); + + $this->assertNotNull(ParticipantRefund::first()->invoice_id); + } + + public function test_release_over_http_without_bank_details_keeps_the_old_way(): void + { + $participant = $this->makeParticipant(); + + $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ + 'amount' => '220,00', + 'reason' => RefundReason::SICKNESS, + 'accountOwner' => '', + 'accountIban' => '', + ]) + ->assertOk() + ->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING); + + Mail::assertSent(RefundReleasedMail::class); + $this->assertSame(0, Invoice::count()); + $this->assertNull(ParticipantRefund::first()->captured_by); + } +}