Dev 4.8.0 #15

Merged
th.guenther merged 11 commits from dev-4.8.0 into main 2026-09-04 09:29:57 +02:00
15 changed files with 994 additions and 39 deletions
Showing only changes of commit efee20c16b - Show all commits
@@ -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;
}
// Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den
// Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen.
$costUnit = $this->costUnit($refund);
if ($costUnit === null) {
$response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. '
. 'Bitte wende dich an die Aktionsleitung.';
Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [
'refund_id' => $refund->id,
'event_id' => $refund->event_id,
]);
return $response;
}
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
$refund->account_owner = $owner;
$refund->account_iban = $iban;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
$this->notify($refund);
$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,
];
}
}
@@ -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');
});
}
};
@@ -4,7 +4,8 @@
<h1>Hallo {{$name}}!</h1>
<p>
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
uns vor.
uns vor. <strong>Du musst nichts weiter tun</strong>: Die Erstattung ist bereits als Abrechnung
eingereicht und wird nun bearbeitet.
</p>
<table style="border-collapse: collapse; margin: 16px 0;">
@@ -16,6 +17,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 +35,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>
@@ -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());
+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);
}
}