Files

278 lines
11 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\Enumerations\RetentionReason;
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(),
// Was beim Verband bleibt, wird hier festgeschrieben: Nach dem Einreichen führt
// `amount_paid` bereits diesen Rest, eine spätere Differenz wäre falsch.
'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(),
'released_by' => currentUser()?->id,
'released_at' => now(),
]);
if ($this->request->submitsDirectly()) {
$this->submitDirectly($refund);
}
return $refund;
});
if (!$this->request->submitsDirectly()) {
$this->notify($refund);
}
$response->success = true;
$response->refund = $refund->fresh();
$response->message = match (true) {
$this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
$this->request->hasBankDetails() => 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
default => '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,
donation: $this->request->donation,
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus.
capturedBy: currentUser()?->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->rejectRetention() ?? $this->rejectBankDetails();
}
/**
* Prüfungen zum einbehaltenen Teil.
*
* Sicherheitsnetz hinter der Oberfläche: Dort erscheint der Absende-Knopf erst, wenn ein Grund
* gewählt ist. Über einen direkten Aufruf ginge das sonst vorbei, und ein einbehaltener Betrag ohne
* Begründung ist in der Buchhaltung nicht haltbar.
*/
private function rejectRetention(): ?string
{
if (!$this->request->hasRetention()) {
return null;
}
$reason = RetentionReason::find($this->request->retentionReason);
if ($reason === null) {
return 'Bitte gib an, warum ein Teil des Beitrags einbehalten wird.';
}
if ($reason->requires_note && trim((string) $this->request->retentionReasonNote) === '') {
return 'Für diesen Einbehaltungsgrund ist eine Erläuterung erforderlich.';
}
return null;
}
/**
* 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
{
// Eine Spende wird nicht ausgezahlt. Kämen beide Angaben zusammen, wäre unklar, was gilt --
// lieber nachfragen als das eine stillschweigend gegen das andere entscheiden.
if ($this->request->donation && (filled($this->request->accountOwner) || filled($this->request->accountIban))) {
return 'Eine Spende braucht keine Bankverbindung.';
}
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
if (!$this->request->donation
&& filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
}
if (!$this->request->submitsDirectly()) {
return null;
}
if ($this->request->hasBankDetails() && !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 -- auch die Spende braucht eine, sie
// wird ja gebucht. 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);
}
/**
* Der Einbehaltungsgrund -- nur, wenn tatsächlich etwas beim Verband bleibt.
*
* Bei voller Erstattung wird ein mitgeschickter Grund verworfen: In der Oberfläche ist das Feld dann
* gar nicht sichtbar, und ein Wert ohne Bezug hätte in der Datenbank nichts zu suchen.
*/
private function retentionReason(): ?string
{
return $this->request->hasRetention() ? $this->request->retentionReason : null;
}
/** Der Freitext dazu -- wie beim Erstattungsgrund nur bei Gründen, die ihn verlangen. */
private function retentionReasonNote(): ?string
{
if (!$this->request->hasRetention()) {
return null;
}
$reason = RetentionReason::find($this->request->retentionReason);
if ($reason === null || !$reason->requires_note) {
return null;
}
return trim((string) $this->request->retentionReasonNote);
}
/**
* 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,
));
}
}
}