Files
mareike/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
T

344 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentResponse;
use App\Enumerations\InvoiceType;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\ParticipantRefund;
use App\Providers\FileWriteProvider;
use App\Providers\UploadFileProvider;
use App\Repositories\CostUnitRepository;
use App\Support\Iban;
use App\ValueObjects\Amount;
use App\ValueObjects\InvoiceFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use RuntimeException;
/**
* 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;
}
// Steht das Konto bereits am Vorgang, stammt es aus dem Zahlungseingang und wurde bei der
// Freigabe festgeschrieben. Dann gilt es -- und die Felder aus dem Request werden verworfen.
//
// Das ist der eigentliche Gewinn der ganzen Übung: Der Teili tippt keine IBAN mehr ab, also
// kann er sich weder vertippen noch lässt sich über einen direkten Aufruf dieser Route eine
// fremde IBAN unterschieben. Erstattet wird auf das Konto, von dem gezahlt wurde, Punkt.
$accountIsKnown = !$this->request->donation && filled($refund->account_iban);
$owner = $accountIsKnown ? (string) $refund->account_owner : trim($this->request->accountOwner);
$iban = $accountIsKnown ? (string) $refund->account_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.
//
// Nimmt die Aktionsleitung die Angaben auf, kreuzt naturgemäß niemand etwas an. Nachvollziehbar
// bleibt es trotzdem: `captured_by` hält fest, wer sie aufgenommen hat, und der Beleg weist es aus.
if (!$this->request->declarationAccepted && $this->request->capturedBy === null) {
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
}
// Wer spendet, gibt kein Konto an -- alles Weitere betrifft nur die Auszahlung.
if (!$this->request->donation) {
// Erstattet wird ausschließlich auf das Konto, von dem der Beitrag kam. Ohne diese
// Bestätigung ließe sich über eine Erstattung Geld auf ein fremdes Konto umleiten.
if (!$this->request->accountDeclarationAccepted && $this->request->capturedBy === null) {
$response->errorTypes['accountDeclaration'] = 'Bitte bestätige, dass es das Konto ist, '
. 'von dem der Beitrag gezahlt wurde.';
}
// Nur was der Teili selbst eingibt, muss geprüft werden. Das bekannte Konto hat die
// Prüfung schon beim Import bestanden -- dort wird eine ungültige IBAN gar nicht erst
// übernommen.
if (!$accountIsKnown) {
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;
}
// Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den
// Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen.
$costUnit = $this->costUnit($refund);
if ($costUnit === null) {
$response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. '
. 'Bitte wende dich an die Aktionsleitung.';
Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [
'refund_id' => $refund->id,
'event_id' => $refund->event_id,
]);
return $response;
}
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
// Bei einer Spende bleiben die Kontofelder leer -- und zwar `null` und nicht Leerstring: Der
// SEPA-Export unterscheidet daran, ob es etwas auszuzahlen gibt.
$refund->account_owner = $this->request->donation ? null : $owner;
$refund->account_iban = $this->request->donation ? null : $iban;
$refund->captured_by = $this->request->capturedBy;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
// Die Spende wird hier ausdrücklich mitgegeben statt am Vorgang abgelesen: Die Abrechnung,
// die sie führt, entsteht erst weiter unten -- dieser Beleg ist ihr Anhang.
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest(
refund: $refund,
donation: $this->request->donation,
))->execute();
$invoice = $this->createInvoice($refund, $costUnit, $document);
// Erst jetzt, nicht früher: Beleg und Anmerkung der Abrechnung weisen den gezahlten Beitrag
// aus und läsen sonst bereits den verrechneten Stand.
$this->settleAmountPaid($refund);
$refund->invoice_id = $invoice->id;
$refund->save();
return $document;
});
$this->notify($refund, $document);
$response->success = true;
$response->message = $this->request->donation
? 'Vielen Dank für deine Spende.'
: 'Vielen Dank. Deine Angaben liegen uns vor.';
return $response;
}
/**
* Die Kostenstelle der Veranstaltung.
*
* Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token.
* Der Repository-Check greift sonst auf `currentUserOrFail()->id` zu und liefe in einen Fehler.
*
* Bewusst ohne Prüfung auf `allow_new`/`archived`: Eine Erstattung fällt oft erst nach dem Ende der
* Veranstaltung an, wenn die Kostenstelle längst geschlossen ist. Sie gehört trotzdem dorthin -- und
* der reguläre Weg über SaveInvoiceController prüft das ebenso wenig.
*/
private function costUnit(ParticipantRefund $refund): ?CostUnit
{
if ($refund->event->cost_unit_id === null) {
return null;
}
return new CostUnitRepository()->getById($refund->event->cost_unit_id, true);
}
/**
* Reicht die Erstattung als gewöhnliche Auslagenabrechnung ein.
*
* Über denselben Command wie jede von Hand erfasste Abrechnung: damit stimmen Nummernkreis, Status
* `new`, die Bestätigungsmail an den Teili und die Benachrichtigung der Kassenwart*innen mit dem
* überein, was die Buchhaltung kennt.
*/
private function createInvoice(
ParticipantRefund $refund,
CostUnit $costUnit,
CreateRefundDocumentResponse $document,
): Invoice {
$participant = $refund->participant;
$invoiceRequest = new CreateInvoiceRequest(
costUnit: $costUnit,
// getOfficialName() und nicht getFullName(): letzteres enthält HTML für die Oberfläche.
contactName: $participant->getOfficialName(),
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0,
receiptFile: $this->storeReceipt($costUnit, $document),
// Hier landet die Entscheidung des Teilis, und nur hier: Die Abrechnung führt sie, der
// Vorgang liest sie über ParticipantRefund::isDonation() zurück.
isDonation: $this->request->donation,
userId: $participant->user_id,
contactEmail: $participant->email_1,
contactPhone: $participant->phone_1,
// Die Bankverbindung stammt aus dem Vorgang, nicht vom Teilnehmer: das Konto kann einem
// Elternteil gehören.
accountOwner: $refund->account_owner,
accountIban: $refund->account_iban,
// MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas
// steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen".
paymentPurpose: null,
notices: $this->notice($refund),
);
$invoiceResponse = new CreateInvoiceCommand($invoiceRequest)->execute();
if (!$invoiceResponse->success || $invoiceResponse->invoice === null) {
// Rollt die Transaktion zurück -- der Vorgang bleibt offen, der Teili kann es erneut versuchen.
throw new RuntimeException('Die Abrechnung zur Beitragserstattung konnte nicht angelegt werden.');
}
return $invoiceResponse->invoice;
}
/**
* Legt den Eigenbeleg dort ab, wo auch hochgeladene Belege liegen, und verpackt ihn für die
* Abrechnung. `CreateInvoiceCommand` speichert nur den Pfad und schreibt selbst keine Dateien.
*/
private function storeReceipt(CostUnit $costUnit, CreateRefundDocumentResponse $document): ?InvoiceFile
{
if (!$document->success) {
return null;
}
$path = UploadFileProvider::directoryFor($costUnit) . '/' . $document->filename;
new FileWriteProvider($path, $document->pdfContent)->writeToFile();
$receipt = new InvoiceFile();
// Beide Eigenschaften sind typisiert und ohne Vorbelegung; gespeichert wird nur `fullPath`.
$receipt->filename = $document->filename;
$receipt->fullPath = $path;
return $receipt;
}
/**
* Die Anmerkung auf der Abrechnung.
*
* Sie nennt den gezahlten Beitrag, weil er am Teilnehmer gleich auf 0 gesetzt wird
* ({@see self::clearAmountPaid()}) -- die Schatzmeisterei kann den Vorgang so nachvollziehen, ohne
* den vorherigen Stand irgendwo suchen zu müssen.
*
* Gekürzt wird nur der vordere, freie Teil: Veranstaltungsname und Grund sind beliebig lang, der
* Betrag darf nie abgeschnitten werden.
*/
private function notice(ParticipantRefund $refund): string
{
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro';
// Die Schatzmeisterei sieht die Abrechnung ohne den Vorgang dahinter. Dass nichts ausgezahlt
// wird, steht zwar im Spendenkennzeichen -- warum keine Bankverbindung dabei ist, aber nur hier.
$subject = $this->request->donation
? 'Spende statt Rückerstattung Teilnahmebeitrag'
: 'Rückerstattung Teilnahmebeitrag';
return Str::limit(sprintf(
'%s %s %s',
$subject,
$refund->event->name,
$refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
}
/**
* Zieht den erstatteten Betrag vom gezahlten Beitrag ab.
*
* Danach führt `amount_paid` genau das, was beim Verband geblieben ist -- bei voller Erstattung also
* 0, bei einer Teilerstattung den einbehaltenen Rest. Auf diesem Feld baut die Einnahmenrechnung der
* Veranstaltung auf; es muss deshalb den tatsächlichen Bestand abbilden und nicht die Zahlung von
* einst. Der ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem
* Beleg.
*/
private function settleAmountPaid(ParticipantRefund $refund): void
{
$participant = $refund->participant;
$paid = $participant->amount_paid?->getAmount() ?? 0.0;
$refunded = $refund->amount?->getAmount() ?? 0.0;
// `max` gegen Rundungsreste: Ein negativer gezahlter Betrag wäre in jeder Auswertung Unsinn.
$participant->amount_paid = new Amount(max(0.0, round($paid - $refunded, 2)), 'Euro');
$participant->save();
}
/**
* Die eigene Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
*
* Sie kommt zusätzlich zu der, die CreateInvoiceCommand verschickt: diese trägt den Beleg, jene ist
* die Quittung des Abrechnungssystems. Erst nach der Transaktion, damit nichts verschickt wird, was
* anschließend zurückgerollt würde.
*
* Scheitert die Belegerzeugung, geht die Mail ohne Anhang raus statt gar nicht.
*/
private function notify(ParticipantRefund $refund, CreateRefundDocumentResponse $document): void
{
$pdf = $document->success ? $document->pdfContent : null;
$filename = $document->success ? $document->filename : null;
$participant = $refund->participant;
$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 RefundAcceptedMail(
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
}
}
}