340 lines
15 KiB
PHP
340 lines
15 KiB
PHP
<?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;
|
||
}
|
||
|
||
$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.
|
||
//
|
||
// 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.';
|
||
}
|
||
|
||
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,
|
||
|
||
// Die folgenden vier gehören zu Reisekosten und Freitext-Typen und sind hier leer. Sie
|
||
// müssen trotzdem stehen: `transportations` hat als einziger Parameter keinen Vorgabewert,
|
||
// und PHP macht damit auch alle optionalen Parameter davor zu Pflichtangaben.
|
||
invoiceTypeExtended: null,
|
||
travelRoute: null,
|
||
distance: null,
|
||
passengers: null,
|
||
transportations: null,
|
||
|
||
// 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,
|
||
));
|
||
}
|
||
}
|
||
}
|