2 Commits
Author SHA1 Message Date
th.guenther 5beb2b1d97 Refundings without contacting the participant 2026-09-03 23:36:21 +02:00
th.guenther efee20c16b Approven von Beitragserstattungen 2026-09-03 23:08:41 +02:00
25 changed files with 1715 additions and 74 deletions
@@ -10,6 +10,7 @@ import AmountInput from "../../../../Views/Components/AmountInput.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue";
import IbanInput from "../../../../Views/Components/IbanInput.vue";
const props = defineProps({
data: {
@@ -47,10 +48,11 @@ const openCancelDialog = ref(false);
const openPartialPaymentDialogSwitch = ref(false);
const openRefundDialogSwitch = ref(false);
// Der Erstattungsdialog. Betrag und Grund werden hier gesetzt; die Bankverbindung erfasst der Teili
// selbst über den Link, den die Freigabe ihm schickt.
const refundForm = reactive({amount: '', reason: '', reasonNote: ''});
const refundErrors = reactive({amount: '', reason: '', reasonNote: ''});
// Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über
// den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits
// vor -- dann wird die Erstattung sofort eingereicht.
const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''});
const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''});
const refundReasons = ref([]);
const refundSaving = ref(false);
@@ -317,9 +319,12 @@ async function openRefundDialog(participant) {
refundForm.amount = participant.amountPaid?.short ?? '';
refundForm.reason = '';
refundForm.reasonNote = '';
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
// Vorgabe ist der übliche Weg über den Teili.
refundForm.captureMode = 'participant';
refundForm.accountOwner = '';
refundForm.accountIban = '';
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
if (refundReasons.value.length === 0) {
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
@@ -333,9 +338,7 @@ function validateRefund() {
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
if (!refundForm.amount || !(amount > 0)) {
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
@@ -349,7 +352,19 @@ function validateRefund() {
refundErrors.reasonNote = 'Bitte erläutere den Grund.';
}
return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote;
// Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
if (refundForm.captureMode === 'management') {
if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
}
if (!refundForm.accountIban.trim()) {
refundErrors.accountIban = 'Bitte gib die IBAN des Kontos ein.';
}
}
return Object.values(refundErrors).every(message => !message);
}
async function execRefund() {
@@ -366,13 +381,22 @@ async function execRefund() {
amount: refundForm.amount,
reason: refundForm.reason,
reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
},
});
if (data?.status === 'success') {
toast.success(data.message);
// Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
// Beim Direktweg steht dort sofort "Erstattet" samt Abrechnungsnummer.
showParticipant.value.refund = data.refund;
// Der gezahlte Beitrag wird beim Einreichen auf 0 gesetzt -- sonst zeigte die Zeile weiter
// den alten Stand, bis jemand neu lädt.
if (data.refund?.status === 'accepted') {
showParticipant.value.amountPaidValue = 0;
}
openRefundDialogSwitch.value = false;
} else {
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
@@ -524,6 +548,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>
@@ -600,8 +627,7 @@ function mailToGroup(groupKey) {
>
<p class="refund-intro">
{{ showParticipant?.fullname }} hat
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt. Nach der Freigabe erhält
der Teili eine E-Mail und trägt seine Bankverbindung selbst ein.
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt.
</p>
<div class="refund-field">
@@ -629,8 +655,49 @@ function mailToGroup(groupKey) {
<ErrorText :message="refundErrors.reasonNote" />
</div>
<!--
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird
sofort eingereicht. Er bekommt den Beleg trotzdem.
-->
<div class="refund-field">
<label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" />
Teilnehmer*in trägt die Bankverbindung selbst ein
</label>
<label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" />
Bankverbindung liegt mir vor
</label>
</div>
<template v-if="refundForm.captureMode === 'management'">
<div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label>
<input
id="refund_account_owner"
v-model="refundForm.accountOwner"
type="text"
class="form-input"
/>
<ErrorText :message="refundErrors.accountOwner" />
</div>
<div class="refund-field">
<label for="refund_account_iban">IBAN</label>
<IbanInput id="refund_account_iban" v-model="refundForm.accountIban" class="form-input" />
<ErrorText :message="refundErrors.accountIban" />
</div>
<p class="refund-hint">
Die Erstattung wird sofort als Abrechnung eingereicht. Der Teili erhält den Beleg per
E-Mail und kann die Angaben prüfen.
</p>
</template>
<button class="button" :disabled="refundSaving" @click="execRefund()">
{{ refundSaving ? 'Wird freigegeben' : 'Erstattung freigeben' }}
<template v-if="refundSaving">Wird gespeichert</template>
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
<template v-else>Erstattung freigeben</template>
</button>
</Modal>
@@ -665,10 +732,32 @@ function mailToGroup(groupKey) {
}
.refund-field select,
.refund-field textarea {
.refund-field textarea,
.refund-field .form-input {
width: 100%;
}
.refund-choice {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #1a1a1a;
cursor: pointer;
}
.refund-choice input {
margin-right: 6px;
}
.refund-hint {
margin-bottom: 14px;
padding: 8px 10px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.85rem;
color: #4b5563;
}
.participants-table {
width: 95%;
margin: 20px auto;
@@ -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.
@@ -52,7 +67,10 @@ class AcceptRefundCommand
// 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.
if (!$this->request->declarationAccepted) {
//
// 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.';
}
@@ -72,13 +90,46 @@ 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->captured_by = $this->request->capturedBy;
$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 +138,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,
@@ -12,6 +12,13 @@ class AcceptRefundRequest
public readonly string $accountIban,
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
public readonly bool $declarationAccepted = false,
/**
* Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag.
*
* Dann kreuzt niemand die Erklärung an -- sie wird stellvertretend aufgenommen, und der Beleg
* weist genau das aus.
*/
public readonly ?int $capturedBy = null,
) {
}
}
@@ -104,6 +104,28 @@ class CreateRefundDocumentCommand
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
*/
/**
* Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat.
*
* Er nennt Name und Datum, weil in diesem Fall niemand die Erklärung darüber angekreuzt hat: Wer den
* Beleg prüft, soll erkennen, dass dort eine aufgenommene Angabe steht und keine Bestätigung des
* Teilis selbst. Beim gewöhnlichen Weg bleibt der Platzhalter leer und der Block fällt weg.
*/
private function captureNote(): string
{
if (!$this->refund->wasCapturedByManagement()) {
return '';
}
$name = $this->refund->capturedBy()->first()?->getOfficialName();
return sprintf(
'Angaben aufgenommen durch %s am %s.',
trim((string) $name) !== '' ? $name : 'die Aktionsleitung',
$this->refund->accepted_at?->format('d.m.Y') ?? ''
);
}
private function declarationText(): string
{
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
@@ -178,6 +200,7 @@ class CreateRefundDocumentCommand
'account_iban' => $this->formatIban((string) $refund->account_iban),
'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(),
'details_table' => $this->renderDetails(),
];
@@ -2,13 +2,18 @@
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Gibt die Erstattung eines Teilnahmebeitrags frei.
@@ -40,28 +45,66 @@ class ReleaseRefundCommand
return $response;
}
$refund = ParticipantRefund::create([
'tenant' => $this->participant->tenant,
'event_id' => $this->participant->event_id,
'event_participant_id' => $this->participant->id,
'token' => Str::random(32),
'status' => ParticipantRefund::STATUS_PENDING,
'amount' => $this->request->amount,
'reason' => $this->request->reason,
'reason_note' => $this->reasonNote(),
'released_by' => auth()->id(),
'released_at' => now(),
]);
// Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die,
// soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion.
$refund = DB::transaction(function (): ParticipantRefund {
$refund = ParticipantRefund::create([
'tenant' => $this->participant->tenant,
'event_id' => $this->participant->event_id,
'event_participant_id' => $this->participant->id,
'token' => Str::random(32),
'status' => ParticipantRefund::STATUS_PENDING,
'amount' => $this->request->amount,
'reason' => $this->request->reason,
'reason_note' => $this->reasonNote(),
'released_by' => auth()->id(),
'released_at' => now(),
]);
$this->notify($refund);
if ($this->request->hasBankDetails()) {
$this->submitDirectly($refund);
}
return $refund;
});
if (!$this->request->hasBankDetails()) {
$this->notify($refund);
}
$response->success = true;
$response->refund = $refund;
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
$response->refund = $refund->fresh();
$response->message = $this->request->hasBankDetails()
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
return $response;
}
/**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
*
* Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen
* des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab.
*/
private function submitDirectly(ParticipantRefund $refund): void
{
$acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: (string) $this->request->accountOwner,
accountIban: (string) $this->request->accountIban,
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus.
capturedBy: auth()->id(),
))->execute();
if (!$acceptResponse->success) {
// Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen
// Vorgang vorfinden, der nirgends eingereicht ist.
throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.');
}
}
/**
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
*
@@ -98,6 +141,34 @@ class ReleaseRefundCommand
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
}
return $this->rejectBankDetails();
}
/**
* Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt
* also keine zweite Gelegenheit, Angaben zu berichtigen.
*/
private function rejectBankDetails(): ?string
{
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
}
if (!$this->request->hasBankDetails()) {
return null;
}
if (!Iban::isValid((string) $this->request->accountIban)) {
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.';
}
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
if ($this->participant->event->cost_unit_id === null) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
}
return null;
}
@@ -119,13 +190,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,
));
@@ -12,6 +12,20 @@ class ReleaseRefundRequest
public readonly Amount $amount,
public readonly string $reason,
public readonly ?string $reasonNote = null,
/**
* Die Bankverbindung, wenn sie der Aktionsleitung bereits vorliegt.
*
* Sind beide gesetzt, entfällt der Umweg über den Teili: die Erstattung wird sofort eingereicht.
* Bleiben sie leer, läuft der übliche Weg über den Bestätigungslink.
*/
public readonly ?string $accountOwner = null,
public readonly ?string $accountIban = null,
) {
}
/** Ob die Erstattung ohne Zutun des Teilis eingereicht werden kann. */
public function hasBankDetails(): bool
{
return filled($this->accountOwner) && filled($this->accountIban);
}
}
@@ -25,6 +25,9 @@ class ReleaseRefundController extends CommonController
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
reason: (string) $request->input('reason'),
reasonNote: Text::nullIfBlank($request->input('reasonNote')),
// Leer, wenn der Teili die Bankverbindung selbst eintragen soll.
accountOwner: Text::nullIfBlank($request->input('accountOwner')),
accountIban: Text::nullIfBlank($request->input('accountIban')),
);
$response = new ReleaseRefundCommand($refundRequest)->execute();
@@ -66,6 +66,7 @@ final class ParticipantRefundTokens
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
],
],
'body' => [
+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,13 @@ 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,
// Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts
// eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken.
'capturedByManagement' => $this->refund->wasCapturedByManagement(),
// 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,
],
);
}
+27
View File
@@ -22,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $reason_note
* @property string|null $account_owner
* @property string|null $account_iban
* @property int|null $captured_by
* @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 +53,8 @@ class ParticipantRefund extends InstancedModel
'reason_note',
'account_owner',
'account_iban',
'captured_by',
'invoice_id',
'released_by',
'released_at',
'accepted_at',
@@ -82,6 +86,29 @@ 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);
}
/**
* Wer die Bankverbindung aufgenommen hat -- leer, wenn der Teili sie selbst eingetragen hat.
*/
public function capturedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'captured_by');
}
/** Ob die Angaben von der Aktionsleitung stammen und nicht vom Teili selbst. */
public function wasCapturedByManagement(): bool
{
return $this->captured_by !== null;
}
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,
];
}
}
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Auslagentyp für automatisch eingereichte Beitragserstattungen.
*
* `invoices.type` ist ein Fremdschlüssel auf `invoice_types.slug` -- ohne diese Zeile scheitert schon das
* Anlegen der Abrechnung. Da `invoice_types` im Repository nirgends geseedet wird, ist die Migration der
* einzige verlässliche Weg; sie schreibt nur, was noch fehlt.
*
* Zur neuen Spalte `selectable`: Der Typ entsteht ausschließlich aus einem Erstattungsvorgang und darf in
* keinem Formular zur Auswahl stehen. Bisher wurden solche Ausnahmen als hartkodierte `continue`-Zweige
* in GlobalDataProvider gelöst (dort für `travelling`) -- eine dritte Sonderregel an zwei Stellen wäre die
* Sorte Code, die beim nächsten Typ wieder wächst. Das Flag sagt selbst, was es tut.
*/
return new class extends Migration {
private const string SLUG = 'participation_refund';
public function up(): void
{
Schema::table('invoice_types', function (Blueprint $table) {
$table->boolean('selectable')->default(true)->after('sort_order');
});
if (DB::table('invoice_types')->where('slug', self::SLUG)->exists()) {
return;
}
DB::table('invoice_types')->insert([
'slug' => self::SLUG,
'name' => 'Beitragserstattung',
// Hinter "Verwaltung" (2) und vor "Sonstige Kosten" (3) wäre die Reihenfolge unklar; der Typ
// taucht ohnehin nur in Summen und Auswertungen auf.
'sort_order' => 4,
'selectable' => false,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
// Nur löschen, wenn keine Abrechnung daran hängt -- der Fremdschlüssel würde es sonst verhindern,
// und ein Rollback soll nicht an fremden Daten scheitern.
if (!DB::table('invoices')->where('type', self::SLUG)->exists()) {
DB::table('invoice_types')->where('slug', self::SLUG)->delete();
}
Schema::table('invoice_types', function (Blueprint $table) {
$table->dropColumn('selectable');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Verbindet den Erstattungsvorgang mit der Abrechnung, die aus ihm entstanden ist.
*
* Damit liest die Aktionsleitung den Auszahlungsstand dort ab, wo er entsteht -- in der Abrechnung --,
* statt ihn am Vorgang zu doppeln und synchron halten zu müssen.
*
* `nullOnDelete`: wird eine Abrechnung gelöscht, bleibt der Erstattungsvorgang bestehen. Er ist die
* Erklärung des Teilis und hat einen eigenen Beleg; er hängt nicht am Leben der Abrechnung.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->foreignId('invoice_id')->nullable()->after('account_iban')
->constrained('invoices', 'id')->nullOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->dropForeign(['invoice_id']);
$table->dropColumn('invoice_id');
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Hält fest, wer die Bankverbindung erfasst hat.
*
* `null` heißt: der Teili hat sie selbst über den Token-Link eingetragen und dabei die Erklärung
* angekreuzt. Ist die Spalte gesetzt, hat die Aktionsleitung die Angaben aufgenommen, weil sie ihr schon
* vorlagen -- der Beleg weist das dann samt Namen aus, damit niemand die Erklärung für eine Bestätigung
* des Teilis hält.
*
* Kein zusätzliches `captured_at`: Der Zeitpunkt ist `accepted_at`, der in diesem Fall mit der Freigabe
* zusammenfällt.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->foreignId('captured_by')->nullable()->after('account_iban')
->constrained('users', 'id')->nullOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->dropForeign(['captured_by']);
$table->dropColumn('captured_by');
});
}
};
@@ -84,6 +84,9 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
/* Die Versicherung des Teilis unter den Angaben */
.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; }
/* Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat -- steht sonst nicht auf dem Beleg. */
.capture-note { font-size: 8pt; color: #555; line-height: 1.55; margin-top: 3mm; }
/* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */
.edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()),
('participant_refund', 'header_sender_return', '<div class="absender-rueck">{sender_name}{if:sender_address_1} &middot; {sender_address_1}{/if:sender_address_1} &middot; {sender_postcode} {sender_city}</div>', 30, 1, NOW(), NOW()),
@@ -130,5 +133,6 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
{details_table}
<div class="declaration">{declaration_text}</div>', 90, 1, NOW(), NOW()),
<div class="declaration">{declaration_text}</div>
{if:capture_note}<div class="capture-note">{capture_note}</div>{/if:capture_note}', 90, 1, NOW(), NOW()),
('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
@@ -2,10 +2,22 @@
<html>
<body>
<h1>Hallo {{$name}}!</h1>
<p>
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
uns vor.
</p>
@if ($capturedByManagement)
<p>
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die
Erstattung ist bereits als Abrechnung eingereicht und wird nun bearbeitet.
</p>
<p>
<strong>Bitte prüfe die unten stehenden Angaben</strong> &ndash; besonders die IBAN.
</p>
@else
<p>
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
uns vor. <strong>Du musst nichts weiter tun</strong>: Die Erstattung ist bereits als Abrechnung
eingereicht und wird nun bearbeitet.
</p>
@endif
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
@@ -16,6 +28,12 @@
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
<td style="padding: 4px 0;">{{$reason}}</td>
</tr>
@if ($invoiceNumber)
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Abrechnungsnummer:</td>
<td style="padding: 4px 0;">{{$invoiceNumber}}</td>
</tr>
@endif
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
<td style="padding: 4px 0;">{{$accountOwner}}</td>
@@ -28,13 +46,21 @@
@if ($hasDocument)
<p>
Im Anhang findest du den Beleg über die Rückerstattung als PDF.
Im Anhang findest du deinen Beleg über die Rückerstattung als PDF &ndash; nur zu deiner
Information, eingereicht ist er bereits.
</p>
@endif
@if ($myInvoicesUrl)
<p>
Den Stand deiner Abrechnung kannst du jederzeit selbst nachsehen: Melde dich in mareike an und
öffne <a href="{{$myInvoicesUrl}}">Meine Abrechnungen</a>.
</p>
@endif
<p>
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde
dich bitte umgehend bei ihr.
Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
</p>
<p>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Enumerations\InvoiceType;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Welche Auslagentypen zur Auswahl stehen.
*
* Die Beitragserstattung entsteht ausschließlich aus einem bestätigten Erstattungsvorgang, der die
* Bankverbindung, den Beleg und den Bezug zur Anmeldung mitbringt. Von Hand gewählt stünde ein leerer
* Rahmen ohne diesen Vorgang da -- deshalb darf sie in keinem Formular auftauchen.
*/
class InvoiceTypeSelectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'email' => 'wm@example.com',
'email_finance' => 'wm-f@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Wilde Möhre e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $tenant);
// Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt.
DB::table('invoice_types')->insert([
['slug' => InvoiceType::INVOICE_TYPE_PROGRAM, 'name' => 'Programmkosten', 'sort_order' => 1, 'selectable' => true],
['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten', 'sort_order' => 3, 'selectable' => true],
]);
}
public function test_the_refund_type_exists_but_is_not_selectable(): void
{
// `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`.
$type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
$this->assertNotNull($type, 'Der Typ muss existieren -- invoices.type ist ein Fremdschlüssel darauf.');
$this->assertFalse($type->selectable);
}
public function test_it_is_missing_from_the_new_invoice_form(): void
{
$response = $this->getJson('/api/v1/core/retrieve-invoice-types');
$response->assertOk();
$slugs = array_column($response->json('invoiceTypes'), 'slug');
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_OTHER, $slugs);
}
public function test_it_is_missing_when_a_treasurer_rebooks(): void
{
// Ohne diese Sperre ließe sich jede beliebige Abrechnung nachträglich zu einer
// Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
$response = $this->getJson('/api/v1/core/retrieve-invoice-types-all');
$response->assertOk();
$slugs = array_column($response->json('invoiceTypes'), 'slug');
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
// "Sonstige Kosten" steht dort bewusst am Ende.
$this->assertSame(InvoiceType::INVOICE_TYPE_OTHER, end($slugs));
}
public function test_selectable_returns_only_choosable_types_in_order(): void
{
$this->assertSame(
[InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
InvoiceType::selectable()->pluck('slug')->all()
);
}
}
+89 -2
View File
@@ -10,10 +10,14 @@ use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocum
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\EfzStatus;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
@@ -67,6 +71,10 @@ class ParticipantRefundTest extends TestCase
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
// Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen.
// `invoice_types.participation_refund` bringt die Migration bereits mit.
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
@@ -112,6 +120,20 @@ class ParticipantRefundTest extends TestCase
]);
}
/** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */
private function makeCostUnit(array $attributes = []): CostUnit
{
return CostUnit::create(array_merge([
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'distance_allowance' => 0.25,
'mail_on_new' => false,
'allow_new' => true,
'archived' => false,
], $attributes));
}
private function makeEvent(array $attributes = []): Event
{
$fee = EventParticipationFee::create([
@@ -125,6 +147,7 @@ class ParticipantRefundTest extends TestCase
]);
return Event::create(array_merge([
'cost_unit_id' => $this->makeCostUnit()->id,
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(),
@@ -532,18 +555,32 @@ class ParticipantRefundTest extends TestCase
|--------------------------------------------------------------------------
*/
public function test_amount_paid_is_untouched_through_the_whole_process(): void
public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void
{
$participant = $this->makeParticipant($this->makeEvent());
// Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag
// unverändert beim Verband.
$refund = $this->release($participant)->refund;
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->accept($refund);
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
// Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
public function test_a_cancelled_release_leaves_amount_paid_alone(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
@@ -565,6 +602,22 @@ class ParticipantRefundTest extends TestCase
]);
}
/** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */
private function makeParticipantUser(): User
{
return User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
public function test_release_over_http_creates_the_refund(): void
{
$this->actingAs($this->makeAdmin());
@@ -753,6 +806,40 @@ class ParticipantRefundTest extends TestCase
$this->assertStringContainsString('Anhang', $html);
}
public function test_the_acceptance_mail_points_at_the_invoice(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]);
$refund = $this->release($participant)->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
// Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits.
$this->assertStringContainsString('musst nichts weiter tun', $html);
$this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html);
$this->assertStringContainsString('/invoice/my-invoices/new', $html);
}
public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void
{
// Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung.
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
$refund = $this->release($participant)->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
$this->assertStringNotContainsString('/invoice/my-invoices/new', $html);
}
public function test_an_empty_second_address_gets_no_mail(): void
{
// Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL.
$this->release($this->makeParticipant($this->makeEvent(), ['email_2' => '']));
Mail::assertSent(RefundReleasedMail::class, 1);
}
public function test_the_acceptance_mail_renders_without_a_document(): void
{
$participant = $this->makeParticipant($this->makeEvent());
+409
View File
@@ -0,0 +1,409 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\Invoice;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Der Direktweg: Liegt der Aktionsleitung die Bankverbindung bereits vor, entfällt der Umweg über den
* Teili -- die Erstattung wird sofort eingereicht.
*/
class RefundDirectCaptureTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private User $management;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
$this->seedTemplate();
// Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang.
$this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN);
$this->actingAs($this->management);
Storage::fake('local');
Mail::fake();
}
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{declaration_text}</p>'
. '{if:capture_note}<p class="capture-note">{capture_note}</p>{/if:capture_note}',
'sort_order' => 20,
]);
}
private function makeUser(string $firstname, string $lastname, string $role): User
{
return User::create([
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'firstname' => $firstname,
'lastname' => $lastname,
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => $role,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
private function makeEvent(array $attributes = []): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug,
'type' => 'participant',
'name' => 'Sippe',
'description' => null,
'amount_standard' => 60.0,
'amount_reduced' => null,
'amount_solidarity' => null,
]);
$costUnit = CostUnit::create([
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'distance_allowance' => 0.25,
'mail_on_new' => false,
'allow_new' => true,
'archived' => false,
]);
return Event::create(array_merge([
'cost_unit_id' => $costUnit->id,
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(),
'location' => 'Ort',
'postal_code' => '00000',
'email' => 'e@example.com',
'start_date' => '2026-07-16',
'end_date' => '2026-07-20',
'early_bird_end' => '2026-06-20',
'registration_final_end' => '2026-07-01',
'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner',
'account_iban' => 'DE00',
'participation_fee_type' => 'fixed',
'participation_fee_1' => $fee->id,
'pay_per_day' => true,
'pay_direct' => false,
'tax_liable' => false,
'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive',
'invoice_key' => 'WM-V-20260701',
], $attributes));
}
private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant
{
$event ??= $this->makeEvent();
$this->sequence++;
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
'firstname' => 'Mika',
'lastname' => 'Muster',
'participation_type' => 'participant',
'fee_type' => 'standard',
'sibling_reduction' => false,
'local_group' => $this->tenant->slug,
'birthday' => '2000-01-01',
'address_1' => 'Beispielstraße 3',
'postcode' => '11111',
'city' => 'Beispielstadt',
'email_1' => 'mika@example.com',
'phone_1' => '0170 0000000',
'arrival_date' => '2026-07-16',
'departure_date' => '2026-07-20',
'arrival_eating' => 1,
'departure_eating' => 1,
'amount' => 300.0,
'amount_paid' => 300.0,
'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
], $attributes));
}
/** Freigabe mit bereits bekannter Bankverbindung. */
private function releaseWithBankDetails(
?EventParticipant $participant = null,
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
float $amount = 220.0,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant ?? $this->makeParticipant(),
amount: new Amount($amount, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: $owner,
accountIban: $iban,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Der Vorgang ist sofort abgeschlossen
|--------------------------------------------------------------------------
*/
public function test_the_refund_is_submitted_right_away(): void
{
$participant = $this->makeParticipant();
$response = $this->releaseWithBankDetails($participant);
$this->assertTrue($response->success);
$this->assertStringContainsString('eingereicht', $response->message);
$refund = ParticipantRefund::first();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertSame('Mika Muster', $refund->account_owner);
$this->assertSame('DE02120300000000202051', $refund->account_iban);
$this->assertNotNull($refund->accepted_at);
}
public function test_the_capturing_person_is_recorded(): void
{
$this->releaseWithBankDetails();
$refund = ParticipantRefund::first();
$this->assertSame($this->management->id, $refund->captured_by);
$this->assertTrue($refund->wasCapturedByManagement());
}
public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void
{
$participant = $this->makeParticipant();
$this->releaseWithBankDetails($participant);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_only_the_receipt_mail_goes_out(): void
{
$this->releaseWithBankDetails();
// Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos.
Mail::assertNotSent(RefundReleasedMail::class);
Mail::assertSent(RefundAcceptedMail::class);
}
/*
|--------------------------------------------------------------------------
| Der Beleg weist aus, wer die Angaben aufgenommen hat
|--------------------------------------------------------------------------
*/
public function test_the_receipt_names_who_captured_the_details(): void
{
$this->releaseWithBankDetails();
$html = $this->renderReceipt(ParticipantRefund::first());
// Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst.
$this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html);
$this->assertStringContainsString('Ich versichere', $html);
}
public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void
{
// Gegenprobe: der gewöhnliche Weg bleibt unverändert.
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $this->makeParticipant(),
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
))->execute()->refund;
$refund->update([
'status' => ParticipantRefund::STATUS_ACCEPTED,
'account_owner' => 'Mika Muster',
'account_iban' => 'DE02120300000000202051',
'accepted_at' => now(),
]);
$this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh()));
}
private function renderReceipt(ParticipantRefund $refund): string
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
$number = new \ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
return new \App\Providers\DocumentTemplateRenderProvider(
DocumentTemplate::TYPE_PARTICIPANT_REFUND
)->render($tokens);
}
/*
|--------------------------------------------------------------------------
| Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen
|--------------------------------------------------------------------------
*/
public function test_an_invalid_iban_stops_everything(): void
{
// Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher.
$response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015');
$this->assertFalse($response->success);
$this->assertStringContainsString('IBAN', $response->message);
$this->assertSame(0, ParticipantRefund::count());
$this->assertSame(0, Invoice::count());
}
public function test_half_filled_bank_details_are_refused(): void
{
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $this->makeParticipant(),
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: 'Mika Muster',
))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_without_a_cost_unit_nothing_is_created(): void
{
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$response = $this->releaseWithBankDetails($participant);
$this->assertFalse($response->success);
$this->assertStringContainsString('Kostenstelle', $response->message);
$this->assertSame(0, ParticipantRefund::count());
$this->assertSame(0, Invoice::count());
// Der gezahlte Beitrag bleibt unangetastet.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Über HTTP
|--------------------------------------------------------------------------
*/
public function test_release_over_http_submits_directly(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED);
$this->assertNotNull(ParticipantRefund::first()->invoice_id);
}
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
'accountOwner' => '',
'accountIban' => '',
])
->assertOk()
->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
Mail::assertSent(RefundReleasedMail::class);
$this->assertSame(0, Invoice::count());
$this->assertNull(ParticipantRefund::first()->captured_by);
}
}
+427
View File
@@ -0,0 +1,427 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\Invoice;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Der Anschluss an die Buchhaltung: aus einer bestätigten Erstattung wird eine gewöhnliche
* Auslagenabrechnung, die den regulären Weg bis zur SEPA-Datei geht.
*/
class RefundInvoiceTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
$this->seedTemplate();
// Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage.
Storage::fake('local');
Mail::fake();
}
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '<div>{recipient_name}</div>{details_table}',
'sort_order' => 20,
]);
}
private function makeCostUnit(array $attributes = []): CostUnit
{
return CostUnit::create(array_merge([
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'distance_allowance' => 0.25,
'mail_on_new' => false,
'allow_new' => true,
'archived' => false,
], $attributes));
}
private function makeEvent(array $attributes = []): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug,
'type' => 'participant',
'name' => 'Sippe',
'description' => null,
'amount_standard' => 60.0,
'amount_reduced' => null,
'amount_solidarity' => null,
]);
return Event::create(array_merge([
'cost_unit_id' => $this->makeCostUnit()->id,
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(),
'location' => 'Ort',
'postal_code' => '00000',
'email' => 'e@example.com',
'start_date' => '2026-07-16',
'end_date' => '2026-07-20',
'early_bird_end' => '2026-06-20',
'registration_final_end' => '2026-07-01',
'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner',
'account_iban' => 'DE00',
'participation_fee_type' => 'fixed',
'participation_fee_1' => $fee->id,
'pay_per_day' => true,
'pay_direct' => false,
'tax_liable' => false,
'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive',
'invoice_key' => 'WM-V-20260701',
], $attributes));
}
private function makeUser(): User
{
return User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
private function makeParticipant(Event $event, array $attributes = []): EventParticipant
{
$this->sequence++;
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser()->id,
'firstname' => 'Mika',
'lastname' => 'Muster',
'participation_type' => 'participant',
'fee_type' => 'standard',
'sibling_reduction' => false,
'local_group' => $this->tenant->slug,
'birthday' => '2000-01-01',
'address_1' => 'Beispielstraße 3',
'postcode' => '11111',
'city' => 'Beispielstadt',
'email_1' => 'mika@example.com',
'phone_1' => '0170 0000000',
'arrival_date' => '2026-07-16',
'departure_date' => '2026-07-20',
'arrival_eating' => 1,
'departure_eating' => 1,
'amount' => 300.0,
'amount_paid' => 300.0,
'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
], $attributes));
}
/** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */
private function runRefund(
?EventParticipant $participant = null,
float $amount = 220.0,
string $reason = RefundReason::SICKNESS,
): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent());
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
))->execute()->refund;
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
return $refund->fresh();
}
/*
|--------------------------------------------------------------------------
| Die Abrechnung entsteht
|--------------------------------------------------------------------------
*/
public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
$this->assertSame($event->cost_unit_id, $invoice->cost_unit_id);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertFalse((bool) $invoice->donation);
}
public function test_contact_details_come_from_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_name);
$this->assertSame('mika@example.com', $invoice->contact_email);
$this->assertSame('0170 0000000', $invoice->contact_phone);
}
public function test_bank_details_come_from_the_refund_not_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_bank_owner);
$this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban);
}
public function test_the_invoice_belongs_to_the_participants_user_account(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertSame($participant->user_id, $invoice->user_id);
// Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id,
// sobald ein Verwendungszweck gesetzt ist.
$this->assertNull($invoice->payment_purpose);
}
public function test_an_anonymous_participant_still_gets_an_invoice(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertNull($invoice->user_id);
}
public function test_the_notice_names_event_reason_and_the_amount_paid(): void
{
$this->runRefund();
// Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird --
// sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen.
$this->assertSame(
'Rückerstattung Teilnahmebeitrag Sommerlager Krankheitsbedingte Absage'
. ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro',
Invoice::first()->comment
);
}
public function test_a_long_notice_keeps_the_amount_paid(): void
{
$event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]);
$this->runRefund($this->makeParticipant($event));
$comment = Invoice::first()->comment;
// Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen.
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment);
$this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.');
}
/*
|--------------------------------------------------------------------------
| Der Eigenbeleg hängt an der Abrechnung
|--------------------------------------------------------------------------
*/
public function test_the_receipt_is_attached_and_stored(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice->document_filename);
// Dasselbe Ablagemuster wie bei hochgeladenen Belegen.
$this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename);
$this->assertStringEndsWith('.pdf', $invoice->document_filename);
Storage::disk('local')->assertExists($invoice->document_filename);
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
}
public function test_the_refund_points_at_its_invoice(): void
{
$refund = $this->runRefund();
$this->assertSame(Invoice::first()->id, $refund->invoice_id);
$this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number);
}
/*
|--------------------------------------------------------------------------
| Die Kostenstelle
|--------------------------------------------------------------------------
*/
public function test_without_a_cost_unit_nothing_is_stored(): void
{
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
))->execute()->refund;
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Aktionsleitung', $response->message);
// Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann.
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertSame(0, Invoice::count());
}
public function test_a_closed_cost_unit_is_still_booked(): void
{
// Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist.
$costUnit = $this->makeCostUnit(['allow_new' => false]);
$event = $this->makeEvent(['cost_unit_id' => $costUnit->id]);
$this->runRefund($this->makeParticipant($event));
$this->assertSame($costUnit->id, Invoice::first()->cost_unit_id);
}
/*
|--------------------------------------------------------------------------
| Der reguläre Weg läuft mit
|--------------------------------------------------------------------------
*/
public function test_the_standard_confirmation_mail_is_sent(): void
{
$this->runRefund();
// Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg.
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
}
public function test_amount_paid_is_cleared_once_the_invoice_exists(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
// Mit der eingereichten Abrechnung ist der Beitrag auf dem Weg zurück und darf in den
// Zahlungsübersichten nicht länger als eingegangen stehen.
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
public function test_the_receipt_is_written_before_the_amount_is_cleared(): void
{
$refund = $this->runRefund();
// Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh genullt,
// stünde dort 0,00 €. Der Beleg selbst liegt als PDF vor; nachprüfbar ist die Reihenfolge an der
// Anmerkung, die im selben Schritt und aus derselben Quelle entsteht.
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
$this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
}
}