Overview for refunds

This commit is contained in:
2026-09-07 10:45:33 +02:00
parent b9795f08f0
commit d07980dd1f
7 changed files with 272 additions and 2 deletions
@@ -60,6 +60,7 @@ const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOw
const refundReasons = ref([]);
const retentionReasons = ref([]);
const refundSaving = ref(false);
const refundResending = ref(false);
const selectedRefundReason = computed(
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
@@ -498,6 +499,32 @@ async function execCancelRefund(participant) {
}
}
/**
* Die Mail zur freigegebenen Erstattung noch einmal schicken -- ohne Rückfrage, es ändert sich nichts am
* Vorgang. Der Guard verhindert, dass ein zweiter Klick eine zweite Mail auslöst, bevor die erste durch ist.
*/
async function execResendRefundMail(participant) {
if (refundResending.value) {
return;
}
refundResending.value = true;
try {
const data = await request('/api/v1/participant-refund/' + participant.refund.token + '/resend-mail', {
method: "POST",
});
if (data?.status === 'success') {
toast.success(data.message);
} else {
toast.error(data?.message ?? 'Die Rückerstattungsmail konnte nicht versendet werden.');
}
} finally {
refundResending.value = false;
}
}
async function downloadRefundDocument(participant) {
const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
@@ -629,8 +656,9 @@ function mailToGroup(groupKey) {
> | Beitrag erstatten</span>
<template v-else-if="participant.refund?.status === 'pending'">
| <strong>Erstattung offen:</strong> {{ participant.refund.amount }},
wartet auf Bankverbindung
| <strong>Rückerstattung vorgemerkt:</strong> {{ participant.refund.amount }}
am {{ participant.refund.releasedAt }}, wartet auf Bankverbindung
<span class="link" @click="execResendRefundMail(participant)">Rückerstattungsmail erneut senden</span>
<span class="link" style="color: #da7070;" @click="execCancelRefund(participant)">Abbrechen</span>
</template>
@@ -0,0 +1,70 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use Illuminate\Support\Facades\Mail;
/**
* Schickt die Mail zu einer freigegebenen Erstattung noch einmal.
*
* Der häufige Fall, wenn ein Vorgang hängt: die erste Mail ist untergegangen. Am Vorgang ändert sich
* dabei nichts -- `released_at` bleibt der Zeitpunkt der Vormerkung, der Token bleibt derselbe, der alte
* Link funktioniert also weiter.
*
* Nur solange die Bankverbindung fehlt: nach der Bestätigung wäre der Link wertlos, nach dem Abbruch
* liefe er ins Leere.
*/
class ResendRefundMailCommand
{
public function __construct(private readonly ResendRefundMailRequest $request)
{
}
public function execute(): ResendRefundMailResponse
{
$response = new ResendRefundMailResponse();
$refund = $this->request->refund;
if (!$refund->isPending()) {
$response->message = $refund->isAccepted()
? 'Diese Erstattung wurde bereits bestätigt -- es gibt nichts mehr nachzureichen.'
: 'Diese Erstattung wurde abgebrochen.';
return $response;
}
$this->notify();
$response->success = true;
$response->message = 'Die Rückerstattungsmail wurde erneut versendet.';
return $response;
}
/**
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Freigabe
* (siehe ReleaseRefundCommand).
*/
private function notify(): void
{
/** @var EventParticipant $participant */
$participant = $this->request->refund->participant()->first();
$recipients = [$participant->email_1];
// `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
// Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere.
if (filled($participant->email_2)) {
$recipients[] = $participant->email_2;
}
foreach ($recipients as $recipient) {
Mail::to($recipient)->send(new RefundReleasedMail(
participant: $participant,
refund: $this->request->refund,
));
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
use App\Models\ParticipantRefund;
class ResendRefundMailRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
class ResendRefundMailResponse
{
public bool $success = false;
public ?string $message = null;
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailCommand;
use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ResendRefundMailController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Token ist hier keine Berechtigung: nachschicken darf nur, wer die Veranstaltung auch
// verwalten kann. `getById()` prüft genau das.
if ($refund === null || $this->events->getById($refund->event_id) === null) {
abort(403, 'Zugriff verweigert.');
}
$response = new ResendRefundMailCommand(new ResendRefundMailRequest($refund))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -4,6 +4,7 @@ use App\Domains\ParticipantRefund\Controllers\AcceptRefundController;
use App\Domains\ParticipantRefund\Controllers\CancelRefundController;
use App\Domains\ParticipantRefund\Controllers\RefundDocumentController;
use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController;
use App\Domains\ParticipantRefund\Controllers\ResendRefundMailController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -17,6 +18,7 @@ Route::prefix('api/v1')
Route::middleware(['auth'])->group(function () {
Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
Route::post('{refundToken}/cancel', CancelRefundController::class);
Route::post('{refundToken}/resend-mail', ResendRefundMailController::class);
Route::get('{refundToken}/document', RefundDocumentController::class);
});
});