From d07980dd1f1ba9ecb4ae3436d0353e7ac0c43c50 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Mon, 7 Sep 2026 10:45:33 +0200
Subject: [PATCH] Overview for refunds
---
.../Event/Views/Partials/ParticipantsList.vue | 32 ++++-
.../ResendRefundMailCommand.php | 70 +++++++++++
.../ResendRefundMailRequest.php | 13 ++
.../ResendRefundMailResponse.php | 10 ++
.../ResendRefundMailController.php | 30 +++++
app/Domains/ParticipantRefund/Routes/api.php | 2 +
tests/Feature/ParticipantRefundTest.php | 117 ++++++++++++++++++
7 files changed, 272 insertions(+), 2 deletions(-)
create mode 100644 app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailCommand.php
create mode 100644 app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailRequest.php
create mode 100644 app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailResponse.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/ResendRefundMailController.php
diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue
index 6bf9f17..72ffa32 100644
--- a/app/Domains/Event/Views/Partials/ParticipantsList.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue
@@ -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
- | Erstattung offen: {{ participant.refund.amount }},
- wartet auf Bankverbindung
+ | Rückerstattung vorgemerkt: {{ participant.refund.amount }}
+ am {{ participant.refund.releasedAt }}, wartet auf Bankverbindung
+ Rückerstattungsmail erneut senden
Abbrechen
diff --git a/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailCommand.php b/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailCommand.php
new file mode 100644
index 0000000..25c24f0
--- /dev/null
+++ b/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailCommand.php
@@ -0,0 +1,70 @@
+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,
+ ));
+ }
+ }
+}
diff --git a/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailRequest.php b/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailRequest.php
new file mode 100644
index 0000000..5d29f6e
--- /dev/null
+++ b/app/Domains/ParticipantRefund/Actions/ResendRefundMail/ResendRefundMailRequest.php
@@ -0,0 +1,13 @@
+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,
+ ]);
+ }
+}
diff --git a/app/Domains/ParticipantRefund/Routes/api.php b/app/Domains/ParticipantRefund/Routes/api.php
index b6549fb..41ca3da 100644
--- a/app/Domains/ParticipantRefund/Routes/api.php
+++ b/app/Domains/ParticipantRefund/Routes/api.php
@@ -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);
});
});
diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php
index a568d80..b2f18d7 100644
--- a/tests/Feature/ParticipantRefundTest.php
+++ b/tests/Feature/ParticipantRefundTest.php
@@ -9,6 +9,8 @@ use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
+use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailCommand;
+use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailRequest;
use App\Enumerations\EfzStatus;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
@@ -243,6 +245,11 @@ class ParticipantRefundTest extends TestCase
))->execute();
}
+ private function resend(?ParticipantRefund $refund)
+ {
+ return new ResendRefundMailCommand(new ResendRefundMailRequest($refund))->execute();
+ }
+
/** Der zweite Weg: Der Teili verzichtet auf die Auszahlung und spendet -- ohne Bankverbindung. */
private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true)
{
@@ -684,6 +691,69 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
}
+ /*
+ |--------------------------------------------------------------------------
+ | Die Mail noch einmal schicken
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_resend_sends_the_release_mail_again_without_touching_the_refund(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
+ $refund = $this->release($participant)->refund;
+ // Erst ab hier zählen: die Mails der Freigabe sind nicht gemeint.
+ Mail::fake();
+
+ $response = $this->resend($refund);
+
+ $this->assertTrue($response->success);
+ Mail::assertSent(RefundReleasedMail::class, 2);
+ Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com'));
+ Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com'));
+
+ // Der Vorgang bleibt, wie er war -- vorgemerkt wurde er beim ersten Mal.
+ $fresh = $refund->fresh();
+ $this->assertSame(ParticipantRefund::STATUS_PENDING, $fresh->status);
+ $this->assertSame($refund->token, $fresh->token);
+ $this->assertEquals($refund->released_at, $fresh->released_at);
+ }
+
+ public function test_resend_sends_only_one_mail_without_contact_person(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+ Mail::fake();
+
+ $this->resend($refund);
+
+ Mail::assertSent(RefundReleasedMail::class, 1);
+ }
+
+ public function test_resend_is_rejected_after_acceptance(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+ $this->accept($refund);
+ Mail::fake();
+
+ $response = $this->resend($refund->fresh());
+
+ $this->assertFalse($response->success);
+ $this->assertStringContainsString('bereits bestätigt', $response->message);
+ Mail::assertNothingSent();
+ }
+
+ public function test_resend_is_rejected_after_a_cancellation(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+ new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
+ Mail::fake();
+
+ $response = $this->resend($refund->fresh());
+
+ $this->assertFalse($response->success);
+ $this->assertStringContainsString('abgebrochen', $response->message);
+ Mail::assertNothingSent();
+ }
+
/*
|--------------------------------------------------------------------------
| Der gezahlte Beitrag bleibt unangetastet
@@ -897,6 +967,53 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
+ public function test_resend_over_http_sends_the_mail_again(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+
+ $this->actingAs($this->makeAdmin());
+ Mail::fake();
+
+ $this->postJson('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
+ ->assertOk()
+ ->assertJsonPath('status', 'success');
+
+ Mail::assertSent(RefundReleasedMail::class, 1);
+ }
+
+ public function test_resend_requires_a_login(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+ Mail::fake();
+
+ $this->post('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
+ ->assertRedirect('/login');
+
+ Mail::assertNothingSent();
+ }
+
+ /** Der Token allein reicht nicht: nachschicken darf nur, wer die Veranstaltung verwalten kann. */
+ public function test_resend_is_forbidden_without_access_to_the_event(): void
+ {
+ $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
+
+ $this->actingAs($this->makeParticipantUser());
+ Mail::fake();
+
+ $this->postJson('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
+ ->assertForbidden();
+
+ Mail::assertNothingSent();
+ }
+
+ public function test_resend_on_an_unknown_token_is_forbidden(): void
+ {
+ $this->actingAs($this->makeAdmin());
+
+ $this->postJson('/api/v1/participant-refund/gibtesnicht/resend-mail')
+ ->assertForbidden();
+ }
+
/*
|--------------------------------------------------------------------------
| Die Mails müssen sich auch wirklich rendern lassen