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,
|
||||
));
|
||||
|
||||
@@ -21,8 +21,34 @@ class InvoiceType extends CommonModel {
|
||||
|
||||
public const INVOICE_TYPE_MANAGEMENT = 'management';
|
||||
|
||||
/**
|
||||
* Erstattung eines Teilnahmebeitrags. Entsteht ausschließlich aus einem bestätigten
|
||||
* Erstattungsvorgang und ist deshalb nicht von Hand wählbar ({@see self::selectable()}).
|
||||
*/
|
||||
public const INVOICE_TYPE_PARTICIPATION_REFUND = 'participation_refund';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'sort_order',
|
||||
'selectable',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'selectable' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Typen, die in einem Formular zur Auswahl stehen dürfen -- nach Sortierung.
|
||||
*
|
||||
* Automatisch vergebene Typen bleiben außen vor: Ihre Abrechnungen entstehen aus einem Vorgang, der
|
||||
* die Daten mitbringt; von Hand gewählt stünde ein leerer Rahmen ohne diesen Vorgang da.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
*/
|
||||
public static function selectable(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return self::where('selectable', true)->orderBy('sort_order')->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class RefundAcceptedMail extends Mailable
|
||||
public function content(): Content
|
||||
{
|
||||
$event = $this->participant->event()->first();
|
||||
$invoice = $this->refund->invoice()->first();
|
||||
|
||||
return new Content(
|
||||
view: 'emails.events.refund_accepted',
|
||||
@@ -52,6 +53,10 @@ class RefundAcceptedMail extends Mailable
|
||||
'accountOwner' => $this->refund->account_owner,
|
||||
'accountIban' => Iban::format((string) $this->refund->account_iban),
|
||||
'hasDocument' => $this->pdfContent !== null,
|
||||
'invoiceNumber' => $invoice?->invoice_number,
|
||||
// Der Hinweis auf "Meine Abrechnungen" nur, wenn die Anmeldung an einem Konto hängt --
|
||||
// die Seite filtert über die Nutzer-Verknüpfung und bliebe sonst leer.
|
||||
'myInvoicesUrl' => $invoice?->user_id !== null ? url('/invoice/my-invoices/new') : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string|null $reason_note
|
||||
* @property string|null $account_owner
|
||||
* @property string|null $account_iban
|
||||
* @property int|null $invoice_id
|
||||
* @property int|null $released_by
|
||||
* @property \Illuminate\Support\Carbon|null $released_at
|
||||
* @property \Illuminate\Support\Carbon|null $accepted_at
|
||||
@@ -51,6 +52,7 @@ class ParticipantRefund extends InstancedModel
|
||||
'reason_note',
|
||||
'account_owner',
|
||||
'account_iban',
|
||||
'invoice_id',
|
||||
'released_by',
|
||||
'released_at',
|
||||
'accepted_at',
|
||||
@@ -82,6 +84,15 @@ class ParticipantRefund extends InstancedModel
|
||||
return $this->belongsTo(RefundReason::class, 'reason', 'slug');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abrechnung, die aus diesem Vorgang entstanden ist. Der Auszahlungsstand steht dort und wird
|
||||
* hier nicht gedoppelt.
|
||||
*/
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
|
||||
@@ -46,9 +46,10 @@ class GlobalDataProvider {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Die Typen für die Neuanlage durch Nutzer*innen. Reisekosten haben ein eigenes Formular. */
|
||||
public function getInvoiceTypes() : JsonResponse {
|
||||
$invoiceTypes = [];
|
||||
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
|
||||
foreach (InvoiceType::selectable() as $invoiceType) {
|
||||
if (
|
||||
$invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING
|
||||
) {
|
||||
@@ -110,9 +111,15 @@ class GlobalDataProvider {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Typen zum Umbuchen durch die Kassenwart*innen -- „Sonstige Kosten" ans Ende.
|
||||
*
|
||||
* Auch hier nur wählbare Typen: sonst ließe sich eine beliebige Abrechnung nachträglich zu einer
|
||||
* Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
|
||||
*/
|
||||
public function getAllInvoiceTypes() : JsonResponse {
|
||||
$invoiceTypes = [];
|
||||
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
|
||||
foreach (InvoiceType::selectable() as $invoiceType) {
|
||||
if (
|
||||
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
|
||||
) {
|
||||
|
||||
@@ -16,13 +16,21 @@ class UploadFileProvider {
|
||||
$this->costUnit = $costUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Ablageverzeichnis der Belege einer Kostenstelle, relativ zur Disk `local`
|
||||
* (Wurzel `storage/app/private`).
|
||||
*
|
||||
* Öffentlich, weil Belege nicht nur aus einem Upload entstehen: Eine Beitragserstattung erzeugt ihren
|
||||
* Eigenbeleg im Speicher und legt ihn über den FileWriteProvider ab -- landen soll er trotzdem dort,
|
||||
* wo alle anderen Belege liegen.
|
||||
*/
|
||||
public static function directoryFor(CostUnit $costUnit) : string {
|
||||
return sprintf('%1$s/invoices/%2$s', app('tenant')->slug, $costUnit->id);
|
||||
}
|
||||
|
||||
public function saveUploadedFile() : ?InvoiceFile {
|
||||
try {
|
||||
$directory = sprintf(
|
||||
'%1$s/invoices/%2$s',
|
||||
app('tenant')->slug,
|
||||
$this->costUnit->id
|
||||
);
|
||||
$directory = self::directoryFor($this->costUnit);
|
||||
|
||||
$filename = $this->normalizeFilename($this->file->getClientOriginalName());
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ class ParticipantRefundResource extends JsonResource
|
||||
'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
|
||||
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
|
||||
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
|
||||
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
|
||||
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user