209 lines
7.8 KiB
PHP
209 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
|
|
|
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
|
use App\Enumerations\RefundReason;
|
|
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
|
use App\Models\EventParticipant;
|
|
use App\Models\ParticipantRefund;
|
|
use App\Repositories\ParticipantRefundRepository;
|
|
use App\Support\Iban;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die,
|
|
// soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion.
|
|
$refund = DB::transaction(function (): ParticipantRefund {
|
|
$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(),
|
|
]);
|
|
|
|
if ($this->request->hasBankDetails()) {
|
|
$this->submitDirectly($refund);
|
|
}
|
|
|
|
return $refund;
|
|
});
|
|
|
|
if (!$this->request->hasBankDetails()) {
|
|
$this->notify($refund);
|
|
}
|
|
|
|
$response->success = true;
|
|
$response->refund = $refund->fresh();
|
|
$response->message = $this->request->hasBankDetails()
|
|
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
|
|
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
|
|
*
|
|
* Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen
|
|
* des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab.
|
|
*/
|
|
private function submitDirectly(ParticipantRefund $refund): void
|
|
{
|
|
$acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest(
|
|
refund: $refund,
|
|
accountOwner: (string) $this->request->accountOwner,
|
|
accountIban: (string) $this->request->accountIban,
|
|
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
|
|
// fest, und der Beleg weist es aus.
|
|
capturedBy: auth()->id(),
|
|
))->execute();
|
|
|
|
if (!$acceptResponse->success) {
|
|
// Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen
|
|
// Vorgang vorfinden, der nirgends eingereicht ist.
|
|
throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 $this->rejectBankDetails();
|
|
}
|
|
|
|
/**
|
|
* Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt
|
|
* also keine zweite Gelegenheit, Angaben zu berichtigen.
|
|
*/
|
|
private function rejectBankDetails(): ?string
|
|
{
|
|
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
|
|
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
|
|
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
|
|
}
|
|
|
|
if (!$this->request->hasBankDetails()) {
|
|
return null;
|
|
}
|
|
|
|
if (!Iban::isValid((string) $this->request->accountIban)) {
|
|
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.';
|
|
}
|
|
|
|
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
|
|
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
|
|
if ($this->participant->event->cost_unit_id === null) {
|
|
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
|
|
}
|
|
|
|
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
|
|
{
|
|
$recipients = [$this->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($this->participant->email_2)) {
|
|
$recipients[] = $this->participant->email_2;
|
|
}
|
|
|
|
foreach ($recipients as $recipient) {
|
|
Mail::to($recipient)->send(new RefundReleasedMail(
|
|
participant: $this->participant,
|
|
refund: $refund,
|
|
));
|
|
}
|
|
}
|
|
}
|