Creating Participation refunds

This commit is contained in:
2026-09-03 21:23:26 +02:00
parent 9c4c28e566
commit 6a183d6498
55 changed files with 3985 additions and 42 deletions
@@ -0,0 +1,121 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Support\Facades\Mail;
/**
* Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung.
*
* Läuft ohne Login -- der Token aus der Mail ist die Autorisierung, dasselbe Modell wie bei
* /print-girocode/{identifier}. Betrag und Grund stehen fest und werden hier nicht angefasst: sie kommen
* aus der Freigabe der Aktionsleitung.
*
* Danach ist der Vorgang festgeschrieben; der Beleg geht mit der Bestätigungsmail raus.
*/
class AcceptRefundCommand
{
/**
* Wortlaut für jeden Fall, in dem der Link nicht (mehr) zu einem offenen Vorgang führt.
*
* Bewusst ein und derselbe Text für „Token unbekannt" und „abgebrochen": eine abgebrochene Freigabe
* soll sich verhalten, als hätte es sie nie gegeben.
*/
public const string NO_OPEN_REFUND = 'Zu deiner Anmeldung liegt keine freigegebene Rückerstattung vor. '
. 'Bitte wende dich an die Aktionsleitung.';
public function __construct(private readonly AcceptRefundRequest $request)
{
}
public function execute(): AcceptRefundResponse
{
$response = new AcceptRefundResponse();
$refund = $this->request->refund;
if ($refund === null || !$refund->isPending()) {
$response->message = $refund?->isAccepted() === true
? 'Deine Angaben liegen uns bereits vor.'
: self::NO_OPEN_REFUND;
return $response;
}
$owner = trim($this->request->accountOwner);
$iban = Iban::normalize($this->request->accountIban);
// 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) {
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
}
if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
}
if ($iban === '') {
$response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
} elseif (!Iban::isValid($iban)) {
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
}
if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.';
return $response;
}
$refund->account_owner = $owner;
$refund->account_iban = $iban;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
$this->notify($refund);
$response->success = true;
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
return $response;
}
/**
* Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
*
* Scheitert die Belegerzeugung, geht die Mail trotzdem raus -- der Vorgang ist gespeichert, und die
* Aktionsleitung kann den Beleg jederzeit erneut abrufen. Ein Fehler hier darf nicht dazu führen,
* dass der Teili gar nichts hört.
*/
private function notify(ParticipantRefund $refund): void
{
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
$pdf = $document->success ? $document->pdfContent : null;
$filename = $document->success ? $document->filename : null;
$participant = $refund->participant;
Mail::to($participant->email_1)->send(new RefundAcceptedMail(
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
if ($participant->email_2 !== null) {
Mail::to($participant->email_2)->send(new RefundAcceptedMail(
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Models\ParticipantRefund;
class AcceptRefundRequest
{
public function __construct(
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
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,
) {
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
class AcceptRefundResponse
{
public bool $success = false;
public ?string $message = null;
/**
* Feldbezogene Fehler für das Formular. Schlüssel sind die Feldnamen des Frontends.
*
* @var array<string, string>
*/
public array $errorTypes = [];
}
@@ -0,0 +1,45 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
use App\Models\ParticipantRefund;
/**
* Bricht eine freigegebene, aber noch nicht bestätigte Erstattung ab.
*
* Danach läuft der Link des Teilis ins Leere und die Anmeldung sieht aus wie vor der Freigabe. Bewusst
* ohne Mail: der Teili soll nicht über etwas informiert werden, das für ihn nie stattgefunden hat --
* die Aktionsleitung klärt das im Zweifel direkt.
*
* Ein bereits bestätigter Vorgang ist unantastbar: dazu gibt es einen Beleg, und der Teili hat seine
* Bankverbindung im Vertrauen darauf herausgegeben.
*/
class CancelRefundCommand
{
public function __construct(private readonly CancelRefundRequest $request)
{
}
public function execute(): CancelRefundResponse
{
$response = new CancelRefundResponse();
$refund = $this->request->refund;
if (!$refund->isPending()) {
$response->message = $refund->isAccepted()
? 'Diese Erstattung wurde bereits bestätigt und kann nicht mehr abgebrochen werden.'
: 'Diese Erstattung wurde bereits abgebrochen.';
return $response;
}
$refund->status = ParticipantRefund::STATUS_CANCELLED;
$refund->cancelled_at = now();
$refund->save();
$response->success = true;
$response->message = 'Die Erstattung wurde abgebrochen.';
return $response;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
use App\Models\ParticipantRefund;
class CancelRefundRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
class CancelRefundResponse
{
public bool $success = false;
public ?string $message = null;
}
@@ -0,0 +1,247 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PageText;
use App\Models\ParticipantRefund;
use App\Models\Tenant;
use App\Providers\DocumentTemplateRenderProvider;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\ValueObjects\Amount;
/**
* Erzeugt den Beleg über die erstattete Teilnahmegebühr als PDF.
*
* Wie bei der Teilnahmerechnung wird nichts gespeichert: die Belegnummer leitet sich aus Veranstaltung
* und Position des Teilis ab, der Inhalt aus dem Erstattungsvorgang. Da ein bestätigter Vorgang nicht
* mehr verändert wird, liefert ein erneuter Abruf denselben Beleg.
*
* Keine Umsatzsteuer: eine Erstattung ist keine Rechnung. Ausgewiesen wird der Betrag, den der Teili
* zurückbekommt. Eine Stornorechnung mit USt-Ausweis wäre eine eigene Dokumentart.
*/
class CreateRefundDocumentCommand
{
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
private ParticipantRefund $refund;
private EventParticipant $participant;
private Event $event;
/** Der Aussteller. Gehört zum Mandanten der Veranstaltung, nicht zum gerade aktiven. */
private ?Tenant $sender;
public function __construct(private readonly CreateRefundDocumentRequest $request)
{
$this->refund = $request->refund;
$this->participant = $request->refund->participant;
$this->event = $request->refund->event;
// Wie in CreateParticipantInvoiceCommand: `$event->tenant` liefert das Slug-Attribut, nicht die
// Relation. Der Aussteller wird live gelesen, damit eine Korrektur an Name oder Anschrift auch
// auf bestehende Veranstaltungen wirkt.
$this->sender = $this->event->tenant()->first();
}
public function execute(): CreateRefundDocumentResponse
{
$response = new CreateRefundDocumentResponse();
if ($this->event->invoice_key === null || $this->participant->invoice_sequence === null) {
$response->message = 'Für diese Anmeldung lässt sich keine Belegnummer bilden.';
return $response;
}
if (!$this->refund->isAccepted()) {
$response->message = 'Der Beleg entsteht erst, wenn die Erstattung bestätigt wurde.';
return $response;
}
$documentNumber = $this->documentNumber();
$html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)
->render($this->buildTokens($documentNumber));
$response->success = true;
$response->documentNumber = $documentNumber;
$response->filename = 'Rueckerstattung-' . $documentNumber . '.pdf';
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
return $response;
}
/**
* Dieselbe Nummer wie die Rechnung, mit angehängtem `-R`. Kein zweiter Nummernkreis: der Beleg
* gehört zu genau einer Anmeldung, und so ist auf einen Blick erkennbar, zu welcher Rechnung.
*/
private function documentNumber(): string
{
return $this->invoiceNumber() . '-R';
}
/** Die Nummer der Teilnahmerechnung -- der Beleg weist sie aus, damit die Zahlung auffindbar ist. */
private function invoiceNumber(): string
{
return sprintf(
'%s-%s',
$this->event->invoice_key,
str_pad((string) $this->participant->invoice_sequence, 4, '0', STR_PAD_LEFT)
);
}
/**
* Der Erklärungssatz aus `page_texts` -- derselbe, den der Teili auf der Bestätigungsseite gelesen
* und angekreuzt hat.
*
* Mit Rückfallwert: fehlt die Zeile in der Datenbank, soll der Beleg trotzdem entstehen. Ohne den
* 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.
*/
private function declarationText(): string
{
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
return trim((string) $text) !== ''
? (string) $text
: 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.';
}
/**
* Leistungszeitraum: bei eintägigen Veranstaltungen nur ein Datum, sonst der Zeitraum.
*/
private function servicePeriod(): string
{
$start = $this->event->start_date;
$end = $this->event->end_date;
if ($start === null) {
return '';
}
if ($end === null || $start->isSameDay($end)) {
return $start->format('d.m.Y');
}
return sprintf('%s %s', $start->format('d.m.Y'), $end->format('d.m.Y'));
}
/**
* @return array<string, string>
*/
private function buildTokens(string $documentNumber): array
{
$participant = $this->participant;
$sender = $this->sender;
$refund = $this->refund;
return [
'document_title' => 'Rückerstattung ' . $documentNumber,
'document_number' => $documentNumber,
// Belegdatum ist der Tag, an dem der Teili bestätigt hat -- da stand der Vorgang fest.
'document_date' => $refund->accepted_at?->format('d.m.Y') ?? '',
'event_name' => (string) $this->event->name,
'service_period' => $this->servicePeriod(),
'unregistered_at' => $participant->unregistered_at?->format('d.m.Y') ?? '',
'sender_name' => $sender?->invoiceSenderName() ?? '',
'sender_address_1' => (string) $sender?->address_1,
'sender_address_2' => (string) $sender?->address_2,
'sender_address_3' => (string) $sender?->address_3,
'sender_postcode' => (string) $sender?->postcode,
'sender_city' => (string) $sender?->city,
'sender_email' => (string) $sender?->email,
'sender_phone' => (string) $sender?->phone,
'sender_tax_number' => (string) $sender?->tax_number,
'sender_vat_id' => (string) $sender?->vat_id,
'recipient_name' => $participant->getOfficialName(),
'recipient_address_1' => (string) $participant->address_1,
'recipient_address_2' => (string) $participant->address_2,
'recipient_postcode' => (string) $participant->postcode,
'recipient_city' => (string) $participant->city,
'paid_amount' => $this->money($participant->amount_paid?->getAmount() ?? 0.0),
'invoice_number' => $this->invoiceNumber(),
'refund_amount' => $this->money($refund->amount?->getAmount() ?? 0.0),
'refund_reason' => $refund->reasonLabel(),
'refund_reason_text' => $refund->reasonText(),
'account_owner' => (string) $refund->account_owner,
'account_iban' => $this->formatIban((string) $refund->account_iban),
'declaration_text' => $this->declarationText(),
'details_table' => $this->renderDetails(),
];
}
/**
* Der generierte Block: wer erklärt, worauf sich die Erstattung bezieht, warum, und auf welches
* Konto sie geht.
*
* Alles, was die Person erklärt, steht in dieser einen Tabelle -- auch die Begründung, die früher als
* Fließtext darunter hing. Was daneben steht (Anschrift im Briefkopf, Veranstaltung im Betreff),
* beschreibt den Vorgang, gehört aber nicht zur Erklärung selbst.
*/
private function renderDetails(): string
{
$refund = $this->refund;
$participant = $this->participant;
$rows = [
// Der Name steht voran: die Tabelle trägt alles, was die Person erklärt, und die Anschrift
// allein im Briefkopf würde den Bezug lösen, sobald der Beleg als Anlage hinter einem
// Deckblatt liegt. Kontoinhaber*in weiter unten kann eine andere Person sein -- etwa ein
// Elternteil.
['Name', e($participant->getOfficialName())],
// Der gezahlte Beitrag ist die Bezugsgröße. Ohne ihn lässt sich bei einer Teilerstattung
// nicht erkennen, warum nur ein Teil zurückgeht -- und die Zusicherung „ich habe den Betrag
// beglichen" bliebe unbelegt, obwohl mareike ihn kennt.
['Gezahlter Teilnahmebeitrag', $this->money($participant->amount_paid?->getAmount() ?? 0.0)],
['Rechnung', e($this->invoiceNumber())],
['Erstattungsbetrag', $this->money($refund->amount?->getAmount() ?? 0.0)],
['Grund', e($refund->reasonLabel())],
];
// Bei einem Freitext-Grund ist die Begründung der Text der Aktionsleitung, sonst der des
// Katalogs. Fehlt beides, entfällt die Zeile -- eine Beschriftung ohne Wert sieht nach Fehler aus.
$reasonText = trim($refund->reasonText());
if ($reasonText !== '') {
$rows[] = ['Begründung', e($reasonText)];
}
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
$html = '';
foreach ($rows as [$key, $value]) {
$html .= sprintf(
'<tr><td class="detail-key">%s</td><td class="detail-val">%s</td></tr>',
e($key),
$value
);
}
return '<table class="detail-table">' . $html . '</table>';
}
/** IBAN in Vierergruppen -- so steht sie auf jedem Beleg und lässt sich abtippen. */
private function formatIban(string $iban): string
{
return trim(chunk_split($iban, 4, ' '));
}
private function money(float $value): string
{
return new Amount($value, 'Euro')->getFormattedAmount() . '&nbsp;&euro;';
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Models\ParticipantRefund;
class CreateRefundDocumentRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
class CreateRefundDocumentResponse
{
public bool $success = false;
public string $documentNumber = '';
public string $filename = '';
public string $pdfContent = '';
public ?string $message = null;
}
@@ -0,0 +1,134 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Enumerations\RefundReason;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Repositories\ParticipantRefundRepository;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Gibt die Erstattung eines Teilnahmebeitrags frei.
*
* Der Vorgang entsteht hier nur als Absichtserklärung: Betrag und Grund stehen fest, die Bankverbindung
* fehlt noch. Der Teili ergänzt sie über den Link in der Mail. `amount_paid` bleibt unangetastet --
* gezahlt hat er bis zur Auszahlung weiterhin, was er gezahlt hat.
*/
class ReleaseRefundCommand
{
private EventParticipant $participant;
private ParticipantRefundRepository $refunds;
public function __construct(private readonly ReleaseRefundRequest $request)
{
$this->participant = $request->participant;
$this->refunds = new ParticipantRefundRepository();
}
public function execute(): ReleaseRefundResponse
{
$response = new ReleaseRefundResponse();
$rejection = $this->reject();
if ($rejection !== null) {
$response->message = $rejection;
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(),
]);
$this->notify($refund);
$response->success = true;
$response->refund = $refund;
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
return $response;
}
/**
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
*
* @return string|null Meldung, oder null wenn nichts dagegen spricht.
*/
private function reject(): ?string
{
if ($this->participant->unregistered_at === null) {
return 'Eine Erstattung ist nur für abgemeldete Teilis möglich.';
}
if ($this->refunds->openFor($this->participant) !== null) {
return 'Für diese Anmeldung läuft bereits eine Erstattung.';
}
$amount = $this->request->amount->getAmount();
if ($amount <= 0) {
return 'Der Erstattungsbetrag muss größer als 0 sein.';
}
// Mehr zurückgeben als eingegangen ist wäre keine Erstattung mehr. Die halbe Cent-Toleranz
// fängt die Rundung des gespeicherten Floats ab.
$paid = $this->participant->amount_paid?->getAmount() ?? 0.0;
if ($amount > $paid + 0.005) {
return 'Der Erstattungsbetrag darf den gezahlten Beitrag nicht übersteigen.';
}
$reason = RefundReason::find($this->request->reason);
if ($reason === null) {
return 'Bitte wähle einen Erstattungsgrund aus.';
}
if ($reason->requires_note && trim((string) $this->request->reasonNote) === '') {
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
}
return null;
}
/** Der Freitext gehört nur zu Gründen, die ihn verlangen -- sonst stünde er ungenutzt in der DB. */
private function reasonNote(): ?string
{
$reason = RefundReason::find($this->request->reason);
if ($reason === null || !$reason->requires_note) {
return null;
}
return trim((string) $this->request->reasonNote);
}
/**
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung
* (siehe SetParticipationStateCommand).
*/
private function notify(ParticipantRefund $refund): void
{
Mail::to($this->participant->email_1)->send(new RefundReleasedMail(
participant: $this->participant,
refund: $refund,
));
if ($this->participant->email_2 !== null) {
Mail::to($this->participant->email_2)->send(new RefundReleasedMail(
participant: $this->participant,
refund: $refund,
));
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
class ReleaseRefundRequest
{
public function __construct(
public readonly EventParticipant $participant,
public readonly Amount $amount,
public readonly string $reason,
public readonly ?string $reasonNote = null,
) {
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Models\ParticipantRefund;
class ReleaseRefundResponse
{
public bool $success = false;
public ?ParticipantRefund $refund = null;
public ?string $message = null;
}
@@ -0,0 +1,37 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Öffentlich erreichbar -- der Token aus der Mail ist die Autorisierung.
*/
class AcceptRefundController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
$acceptRequest = new AcceptRefundRequest(
refund: $refund,
accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'),
);
$response = new AcceptRefundCommand($acceptRequest)->execute();
// Immer Status 200: der HttpClient des Frontends verwirft Antworten mit Fehlerstatus, die
// Feldfehler kämen dort nie an (siehe resources/js/components/HttpClient.js).
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'error_types' => $response->errorTypes,
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundCommand;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CancelRefundController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Token ist hier keine Berechtigung: abbrechen 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 CancelRefundCommand(new CancelRefundRequest($refund))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Scopes\CommonController;
use Illuminate\Http\Response;
class RefundDocumentController extends CommonController
{
public function __invoke(string $refundToken): Response
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Beleg enthält die Bankverbindung -- er gehört der Aktionsleitung, nicht dem Token.
if ($refund === null || $this->events->getById($refund->event_id) === null) {
abort(403, 'Zugriff verweigert.');
}
$documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
if (!$documentResponse->success) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
}
return response($documentResponse->pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $documentResponse->filename . '"',
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Inertia\Response;
/**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
*
* Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
*/
class RefundPageController extends CommonController
{
public function __invoke(string $refundToken): Response
{
$refund = $this->participantRefunds->getByToken($refundToken);
return new InertiaProvider('ParticipantRefund/RefundPage', $this->props($refund))->render();
}
/**
* @return array<string, mixed>
*/
private function props(?ParticipantRefund $refund): array
{
// Unbekannt und abgebrochen sind für den Teili derselbe Zustand: es gibt nichts zu tun.
if ($refund === null || $refund->status === ParticipantRefund::STATUS_CANCELLED) {
return [
'state' => 'unavailable',
'message' => AcceptRefundCommand::NO_OPEN_REFUND,
];
}
$participant = $refund->participant;
$event = $refund->event;
$common = [
'token' => $refund->token,
'name' => $participant->getNicename(),
'eventTitle' => $event->name,
'eventEmail' => $event->email,
'amount' => $refund->amount?->toString() ?? '0,00 Euro',
'reason' => $refund->reasonLabel(),
'reasonNote' => $refund->reason_note,
];
if ($refund->isAccepted()) {
return array_merge($common, [
'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'),
]);
}
return array_merge($common, ['state' => 'open']);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Scopes\CommonController;
use App\Support\Text;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReleaseRefundController extends CommonController
{
public function __invoke(string $participantIdentifier, Request $request): JsonResponse
{
$participant = $this->eventParticipants->getByIdentifier($participantIdentifier, $this->events);
if ($participant === null) {
abort(403, 'Zugriff verweigert.');
}
$refundRequest = new ReleaseRefundRequest(
participant: $participant,
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
reason: (string) $request->input('reason'),
reasonNote: Text::nullIfBlank($request->input('reasonNote')),
);
$response = new ReleaseRefundCommand($refundRequest)->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'refund' => $response->refund?->toResource()->toArray($request),
]);
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Domains\ParticipantRefund;
/**
* Katalog der Platzhalter, die in der Vorlage des Erstattungsbelegs zur Verfügung stehen.
*
* Dient wie {@see \App\Domains\ParticipantInvoice\ParticipantInvoiceTokens} zwei Zwecken: der
* Platzhalter-Liste in der Vorlagen-Verwaltung und den Beispieldaten für die Vorschau. Die echten Werte
* setzt
* {@see \App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand}
* zusammen -- dass beide Seiten dieselben Namen kennen, sichert ein Test ab.
*/
final class ParticipantRefundTokens
{
/**
* @return array<string, array{label: string, tokens: array<string, array{description: string, sample: string}>}>
*/
public static function groups(): array
{
return [
'document' => [
'label' => 'Dokument',
'tokens' => [
'document_title' => ['description' => 'Titel des PDF-Dokuments', 'sample' => 'Rückerstattung WM-V-20260701-0005-R'],
'document_number' => ['description' => 'Belegnummer', 'sample' => 'WM-V-20260701-0005-R'],
'document_date' => ['description' => 'Datum der Bestätigung durch die teilnehmende Person', 'sample' => '18.06.2026'],
'event_name' => ['description' => 'Name der Veranstaltung', 'sample' => 'Sommerlager'],
'service_period' => ['description' => 'Zeitraum der Veranstaltung', 'sample' => '16.07.2026 20.07.2026'],
'unregistered_at' => ['description' => 'Datum der Abmeldung', 'sample' => '12.06.2026'],
],
],
'sender' => [
'label' => 'Absender',
'tokens' => [
'sender_name' => ['description' => 'Absender (Standard: Name des Mandanten)', 'sample' => 'BdP Landesverband Sachsen e.V.'],
'sender_address_1' => ['description' => 'Straße und Hausnummer', 'sample' => 'Musterweg 1'],
'sender_address_2' => ['description' => 'Adresszusatz, z. B. „c/o …"', 'sample' => 'c/o Mustermensch'],
'sender_address_3' => ['description' => 'Weiterer Adresszusatz', 'sample' => ''],
'sender_postcode' => ['description' => 'Postleitzahl', 'sample' => '01623'],
'sender_city' => ['description' => 'Ort', 'sample' => 'Lommatzsch'],
'sender_email' => ['description' => 'E-Mail-Adresse', 'sample' => 'kontakt@example.com'],
'sender_phone' => ['description' => 'Telefonnummer', 'sample' => '0351 1234567'],
'sender_tax_number' => ['description' => 'Steuernummer', 'sample' => '201/123/45678'],
'sender_vat_id' => ['description' => 'USt-IdNr.', 'sample' => ''],
],
],
'recipient' => [
'label' => 'Empfänger',
'tokens' => [
'recipient_name' => ['description' => 'Name der teilnehmenden Person', 'sample' => 'Mika Muster'],
'recipient_address_1' => ['description' => 'Straße und Hausnummer', 'sample' => 'Beispielstraße 3'],
'recipient_address_2' => ['description' => 'Adresszusatz', 'sample' => ''],
'recipient_postcode' => ['description' => 'Postleitzahl', 'sample' => '11111'],
'recipient_city' => ['description' => 'Ort', 'sample' => 'Beispielstadt'],
],
],
'refund' => [
'label' => 'Erstattung',
'tokens' => [
'paid_amount' => ['description' => 'Bereits gezahlter Teilnahmebeitrag', 'sample' => '300,00 €'],
'invoice_number' => ['description' => 'Nummer der Teilnahmerechnung', 'sample' => 'WM-V-20260701-0005'],
'refund_amount' => ['description' => 'Erstattungsbetrag', 'sample' => '220,00 €'],
'refund_reason' => ['description' => 'Bezeichnung des Grundes', 'sample' => 'Krankheitsbedingte Absage'],
'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'],
'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.'],
],
],
'body' => [
'label' => 'Beleginhalt (generiert)',
'tokens' => [
'details_table' => ['description' => 'Tabelle mit Name, gezahltem Beitrag, Rechnung, Erstattungsbetrag, Grund, Begründung und Bankverbindung', 'sample' => self::sampleDetails()],
],
],
];
}
/**
* Beispielwerte für die Vorschau.
*
* @return array<string, string>
*/
public static function sample(): array
{
$sample = [];
foreach (self::groups() as $group) {
foreach ($group['tokens'] as $name => $token) {
$sample[$name] = $token['sample'];
}
}
return $sample;
}
/** @return array<int, string> */
public static function names(): array
{
return array_keys(self::sample());
}
private static function sampleDetails(): string
{
return '<table class="detail-table">'
. '<tr><td class="detail-key">Name</td><td class="detail-val">Mika Muster</td></tr>'
. '<tr><td class="detail-key">Gezahlter Teilnahmebeitrag</td><td class="detail-val">300,00&nbsp;&euro;</td></tr>'
. '<tr><td class="detail-key">Rechnung</td><td class="detail-val">WM-V-20260701-0005</td></tr>'
. '<tr><td class="detail-key">Erstattungsbetrag</td><td class="detail-val">220,00&nbsp;&euro;</td></tr>'
. '<tr><td class="detail-key">Grund</td><td class="detail-val">Krankheitsbedingte Absage</td></tr>'
. '<tr><td class="detail-key">Begr&uuml;ndung</td><td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td></tr>'
. '<tr><td class="detail-key">Kontoinhaber*in</td><td class="detail-val">Mika Muster</td></tr>'
. '<tr><td class="detail-key">IBAN</td><td class="detail-val">DE02 1203 0000 0000 2020 51</td></tr>'
. '</table>';
}
}
@@ -0,0 +1,24 @@
<?php
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\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::prefix('api/v1')
->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('participant-refund')->group(function () {
// Der Teili bestätigt über den Token aus seiner Mail -- ohne Login.
Route::post('{refundToken}/accept', AcceptRefundController::class);
Route::middleware(['auth'])->group(function () {
Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
Route::post('{refundToken}/cancel', CancelRefundController::class);
Route::get('{refundToken}/document', RefundDocumentController::class);
});
});
});
});
@@ -0,0 +1,10 @@
<?php
use App\Domains\ParticipantRefund\Controllers\RefundPageController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
// Bewusst ohne `auth`: die Seite ist der Link aus der Mail an den Teili.
Route::get('/rueckerstattung/{refundToken}', RefundPageController::class);
});
@@ -0,0 +1,249 @@
<script setup>
import {reactive, ref} from 'vue'
import {toast} from 'vue3-toastify'
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
import ErrorText from '../../../Views/Components/ErrorText.vue'
import IbanInput from '../../../Views/Components/IbanInput.vue'
import TextResource from '../../../Views/Components/TextResource.vue'
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
const {request} = useAjax()
/**
* `state` steuert die ganze Seite:
* open -- Formular für die Bankverbindung
* accepted -- Angaben liegen vor, nichts mehr zu tun
* unavailable -- Token unbekannt oder Erstattung abgebrochen
*/
const props = defineProps({
state: String,
message: String,
token: String,
name: String,
eventTitle: String,
eventEmail: String,
amount: String,
reason: String,
reasonNote: String,
acceptedAt: String,
})
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false})
const errors = reactive({accountOwner: '', accountIban: '', declaration: ''})
const saving = ref(false)
function validate() {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
return !errors.accountOwner && !errors.accountIban && !errors.declaration
}
async function submit() {
if (!validate() || saving.value) return
saving.value = true
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body: {
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
},
})
if (!response) {
toast.error('Deine Angaben konnten nicht gespeichert werden. Bitte versuche es später erneut.')
return
}
// Feldbezogene Fehler kommen mit Status 200 als `error_types` zurück -- der HttpClient des
// Projekts verwirft Antworten mit Fehlerstatus.
if (response.status !== 'success') {
Object.keys(response.error_types ?? {}).forEach((key) => {
if (key in errors) errors[key] = response.error_types[key]
})
toast.error(response.message ?? 'Bitte prüfe deine Angaben.')
return
}
toast.success(response.message)
state.value = 'accepted'
} finally {
saving.value = false
}
}
</script>
<template>
<AppLayout title="Rückerstattung">
<shadowed-box style="max-width: 640px; margin: 60px auto; padding: 24px;">
<template v-if="state === 'unavailable'">
<h2>Rückerstattung</h2>
<p class="hint">{{ props.message }}</p>
</template>
<template v-else>
<h2>Rückerstattung deines Teilnahmebeitrags</h2>
<p>
Hallo {{ props.name }}, für deine Abmeldung von der Veranstaltung
<strong>{{ props.eventTitle }}</strong> wurde eine Rückerstattung freigegeben.
</p>
<table class="summary">
<tr>
<th>Betrag</th>
<td><strong>{{ props.amount }}</strong></td>
</tr>
<tr>
<th>Grund</th>
<td>{{ props.reason }}</td>
</tr>
<tr v-if="props.reasonNote">
<th>Anmerkung</th>
<td>{{ props.reasonNote }}</td>
</tr>
</table>
<template v-if="state === 'accepted'">
<p class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
</p>
</template>
<template v-else>
<p class="hint">
Der Betrag steht fest und lässt sich hier nicht ändern. Hast du dazu Fragen, wende
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<h3>Auf welches Konto sollen wir überweisen?</h3>
<form @submit.prevent="submit">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
<!--
Die Erklärung, die anschließend auf dem Eigenbeleg steht. Sie muss hier
gelesen und angekreuzt werden -- sonst schriebe der Beleg dem Teili eine
Zusicherung zu, die er nie abgegeben hat.
-->
<div class="declaration">
<input
id="refund-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button class="button" type="submit" :disabled="saving || !form.declarationAccepted">
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</form>
</template>
</template>
</shadowed-box>
</AppLayout>
</template>
<style scoped>
h2 {
margin-bottom: 16px;
}
h3 {
margin: 24px 0 12px;
}
.hint {
margin: 16px 0;
padding: 10px 12px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.9rem;
color: #4b5563;
}
.summary {
border-collapse: collapse;
margin: 20px 0;
}
.summary th {
text-align: left;
padding: 6px 24px 6px 0;
color: #555;
font-weight: normal;
white-space: nowrap;
}
.summary td {
padding: 6px 0;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 0.9rem;
color: #4b5563;
}
.field .form-input {
width: 100%;
}
.declaration {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 20px 0 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.declaration input {
margin-top: 3px;
flex-shrink: 0;
}
.declaration :deep(label) {
cursor: pointer;
}
</style>