Creating Participation refunds
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+247
@@ -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() . ' €';
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
|
||||
|
||||
use App\Models\ParticipantRefund;
|
||||
|
||||
class CreateRefundDocumentRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ParticipantRefund $refund,
|
||||
) {
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user