Approven von Beitragserstattungen

This commit is contained in:
2026-09-03 23:08:41 +02:00
parent b01288b5e0
commit efee20c16b
15 changed files with 994 additions and 39 deletions
@@ -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">
&middot; 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,
));
+26
View File
@@ -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,
],
);
}
+11
View File
@@ -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;
+9 -2
View File
@@ -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
) {
+13 -5
View File
@@ -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,
];
}
}