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 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