Approven von Beitragserstattungen
This commit is contained in:
@@ -524,6 +524,9 @@ function mailToGroup(groupKey) {
|
||||
|
||||
<template v-else-if="participant.refund?.status === 'accepted'">
|
||||
| <strong>Erstattet:</strong> {{ participant.refund.amount }}
|
||||
<template v-if="participant.refund.invoiceNumber">
|
||||
· Abrechnung {{ participant.refund.invoiceNumber }}
|
||||
</template>
|
||||
<span class="link" @click="downloadRefundDocument(participant)">Beleg</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
|
||||
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.
|
||||
@@ -72,13 +87,45 @@ class AcceptRefundCommand
|
||||
return $response;
|
||||
}
|
||||
|
||||
$refund->account_owner = $owner;
|
||||
$refund->account_iban = $iban;
|
||||
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
|
||||
$refund->accepted_at = now();
|
||||
$refund->save();
|
||||
// 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.';
|
||||
|
||||
$this->notify($refund);
|
||||
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) {
|
||||
$refund->account_owner = $owner;
|
||||
$refund->account_iban = $iban;
|
||||
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
|
||||
$refund->accepted_at = now();
|
||||
$refund->save();
|
||||
|
||||
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->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 die 0.
|
||||
$this->clearAmountPaid($refund);
|
||||
|
||||
$refund->invoice_id = $invoice->id;
|
||||
$refund->save();
|
||||
|
||||
return $document;
|
||||
});
|
||||
|
||||
$this->notify($refund, $document);
|
||||
|
||||
$response->success = true;
|
||||
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
|
||||
@@ -87,30 +134,162 @@ class AcceptRefundCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
|
||||
* Die Kostenstelle der Veranstaltung.
|
||||
*
|
||||
* 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.
|
||||
* Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token.
|
||||
* Der Repository-Check greift sonst auf `auth()->user()->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 notify(ParticipantRefund $refund): void
|
||||
private function costUnit(ParticipantRefund $refund): ?CostUnit
|
||||
{
|
||||
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
|
||||
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),
|
||||
isDonation: false,
|
||||
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';
|
||||
|
||||
return Str::limit(sprintf(
|
||||
'Rückerstattung Teilnahmebeitrag %s – %s',
|
||||
$refund->event->name,
|
||||
$refund->reasonLabel()
|
||||
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den gezahlten Beitrag des Teilis auf 0.
|
||||
*
|
||||
* Mit der eingereichten Abrechnung ist der Beitrag nicht mehr beim Verband, sondern auf dem Weg
|
||||
* zurück -- die Zahlungsübersichten der Aktionsleitung sollen ihn nicht länger als offen führen. Der
|
||||
* ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem Beleg.
|
||||
*/
|
||||
private function clearAmountPaid(ParticipantRefund $refund): void
|
||||
{
|
||||
$participant = $refund->participant;
|
||||
$participant->amount_paid = new Amount(0.0, '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;
|
||||
|
||||
Mail::to($participant->email_1)->send(new RefundAcceptedMail(
|
||||
participant: $participant,
|
||||
refund: $refund,
|
||||
pdfContent: $pdf,
|
||||
pdfFilename: $filename,
|
||||
));
|
||||
$recipients = [$participant->email_1];
|
||||
|
||||
if ($participant->email_2 !== null) {
|
||||
Mail::to($participant->email_2)->send(new RefundAcceptedMail(
|
||||
// `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,
|
||||
|
||||
@@ -119,13 +119,16 @@ class ReleaseRefundCommand
|
||||
*/
|
||||
private function notify(ParticipantRefund $refund): void
|
||||
{
|
||||
Mail::to($this->participant->email_1)->send(new RefundReleasedMail(
|
||||
participant: $this->participant,
|
||||
refund: $refund,
|
||||
));
|
||||
$recipients = [$this->participant->email_1];
|
||||
|
||||
if ($this->participant->email_2 !== null) {
|
||||
Mail::to($this->participant->email_2)->send(new RefundReleasedMail(
|
||||
// `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,
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user