Overview for refunds
This commit is contained in:
@@ -60,6 +60,7 @@ const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOw
|
|||||||
const refundReasons = ref([]);
|
const refundReasons = ref([]);
|
||||||
const retentionReasons = ref([]);
|
const retentionReasons = ref([]);
|
||||||
const refundSaving = ref(false);
|
const refundSaving = ref(false);
|
||||||
|
const refundResending = ref(false);
|
||||||
|
|
||||||
const selectedRefundReason = computed(
|
const selectedRefundReason = computed(
|
||||||
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
|
() => 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) {
|
async function downloadRefundDocument(participant) {
|
||||||
const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
|
const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
|
||||||
|
|
||||||
@@ -629,8 +656,9 @@ function mailToGroup(groupKey) {
|
|||||||
> | Beitrag erstatten</span>
|
> | Beitrag erstatten</span>
|
||||||
|
|
||||||
<template v-else-if="participant.refund?.status === 'pending'">
|
<template v-else-if="participant.refund?.status === 'pending'">
|
||||||
| <strong>Erstattung offen:</strong> {{ participant.refund.amount }},
|
| <strong>Rückerstattung vorgemerkt:</strong> {{ participant.refund.amount }}
|
||||||
wartet auf Bankverbindung
|
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>
|
<span class="link" style="color: #da7070;" @click="execCancelRefund(participant)">Abbrechen</span>
|
||||||
</template>
|
</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\CancelRefundController;
|
||||||
use App\Domains\ParticipantRefund\Controllers\RefundDocumentController;
|
use App\Domains\ParticipantRefund\Controllers\RefundDocumentController;
|
||||||
use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController;
|
use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController;
|
||||||
|
use App\Domains\ParticipantRefund\Controllers\ResendRefundMailController;
|
||||||
use App\Middleware\IdentifyTenant;
|
use App\Middleware\IdentifyTenant;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ Route::prefix('api/v1')
|
|||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
|
Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
|
||||||
Route::post('{refundToken}/cancel', CancelRefundController::class);
|
Route::post('{refundToken}/cancel', CancelRefundController::class);
|
||||||
|
Route::post('{refundToken}/resend-mail', ResendRefundMailController::class);
|
||||||
Route::get('{refundToken}/document', RefundDocumentController::class);
|
Route::get('{refundToken}/document', RefundDocumentController::class);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
|
|||||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
||||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
||||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
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\EfzStatus;
|
||||||
use App\Enumerations\CostUnitType;
|
use App\Enumerations\CostUnitType;
|
||||||
use App\Enumerations\InvoiceStatus;
|
use App\Enumerations\InvoiceStatus;
|
||||||
@@ -243,6 +245,11 @@ class ParticipantRefundTest extends TestCase
|
|||||||
))->execute();
|
))->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. */
|
/** Der zweite Weg: Der Teili verzichtet auf die Auszahlung und spendet -- ohne Bankverbindung. */
|
||||||
private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true)
|
private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true)
|
||||||
{
|
{
|
||||||
@@ -684,6 +691,69 @@ class ParticipantRefundTest extends TestCase
|
|||||||
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
|
$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
|
| Der gezahlte Beitrag bleibt unangetastet
|
||||||
@@ -897,6 +967,53 @@ class ParticipantRefundTest extends TestCase
|
|||||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
|
$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
|
| Die Mails müssen sich auch wirklich rendern lassen
|
||||||
|
|||||||
Reference in New Issue
Block a user