+ Die Vorlage gilt für alle Mandanten; die eingesetzten Werte kommen je
+ Rechnung aus dem jeweiligen Mandanten. Sie liegt in der Datenbank und wird von einem
+ Update nicht überschrieben.
+
+
+
+
+
+
+
+
+
+
+
+ Dieser Block wird beim Erzeugen der Rechnung aus den Daten der Anmeldung
+ zusammengesetzt und lässt sich nicht bearbeiten.
+
+
+
+ Enthält den Seitenaufbau bzw. die Gestaltung und wird deshalb im Quelltext
+ bearbeitet. Für Adresse, Logo oder Fußnote ist das nicht nötig.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Platzhalter
+
+ Klicken fügt den Platzhalter an der Einfügemarke ein — in
+ {{ current?.label ?? '—' }}. Mit
+ {if:name}…{/if:name} lässt sich ein Abschnitt weglassen, solange
+ der Platzhalter leer ist.
+
+
+
+
{{ group.label }}
+
+
+
+
+
+
+
Bilder
+
+ Liegen in der Datenbank, damit ein Update sie nicht überschreibt. „Einfügen"
+ schreibt in {{ current?.label ?? '—' }} — im CSS als
+ url("…"), anders kann dompdf ein Bild dort nicht verarbeiten.
+
+
+
+
+
+
+ {{ asset.name }}
+ {{ asset.label }}
+
+
+ {{ asset.token }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Vorschau
+
Mit Beispieldaten, ohne zu speichern.
+
+
Noch keine Vorschau erzeugt.
+
+
+
+
+
+
+
diff --git a/app/Domains/Admin/Views/Partials/TenantContact.vue b/app/Domains/Admin/Views/Partials/TenantContact.vue
index 11843a5..ee73fa6 100644
--- a/app/Domains/Admin/Views/Partials/TenantContact.vue
+++ b/app/Domains/Admin/Views/Partials/TenantContact.vue
@@ -13,9 +13,17 @@ const props = defineProps({
const { request } = useAjax()
const editing = ref(false)
+// Solange kein eigener Absender eingetragen ist, steht auf der Rechnung der Name des Mandanten.
+const senderFallback = props.data.name ?? ''
+
const form = ref({
+ invoice_sender_name: props.data.invoice_sender_name ?? '',
email: props.data.email ?? '',
email_finance: props.data.email_finance ?? '',
+ phone: props.data.phone ?? '',
+ address_1: props.data.address_1 ?? '',
+ address_2: props.data.address_2 ?? '',
+ address_3: props.data.address_3 ?? '',
postcode: props.data.postcode ?? '',
city: props.data.city ?? '',
})
@@ -39,16 +47,49 @@ async function save() {
+
+
Rechnungs-Absender:
+
+ {{ form.invoice_sender_name || senderFallback }}
+
+ Name des Mandanten, da nichts Eigenes hinterlegt ist
+
+
+
Email:
{{ form.email }}
Email Schatzmeister*in:
{{ form.email_finance }}
+
Telefon:
{{ form.phone || '—' }}
+
Straße & Hausnummer:
{{ form.address_1 || '—' }}
+
Adresszusatz:
{{ form.address_2 || '—' }}
+
Weiterer Zusatz:
{{ form.address_3 || '—' }}
Postleitzahl:
{{ form.postcode }}
Ort:
{{ form.city }}
+
+
+ Ohne Straße & Hausnummer ist die Anschrift auf Rechnungen unvollständig
+ (§ 14 Abs. 4 UStG).
+
+
+
Rechnungs-Absender:
+
+
+
+ Vollständige Bezeichnung mit Rechtsform, wie sie auf der Rechnung stehen soll.
+ Leer lassen, um den Namen des Mandanten zu verwenden.
+
+
+
+
+ Pflichtangabe auf Rechnungen — alternativ die USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG)
+
+
+
+
+
USt-IdNr.
+
+
+
+
Rechnungs-Präfix
+
+
+
+ Erster Block der Rechnungsnummer, z. B. WM in
+ WM-V-20260701-0005.
+
+ Eine Änderung wirkt nur auf künftige Veranstaltungen; bereits angelegte behalten
+ ihre Nummer.
+
+
+ Änderbar nur in der Mandanten-Verwaltung.
+
+
+
+
@@ -195,6 +253,25 @@ async function save() {
font-style: italic;
}
+.field-hint {
+ display: block;
+ margin-top: 4px;
+ font-size: 0.8rem;
+ color: #6b7280;
+}
+
+.field-hint code {
+ padding: 1px 4px;
+ background-color: #f3f4f6;
+ border-radius: 3px;
+ font-size: 0.95em;
+}
+
+.form-input:disabled {
+ background-color: #f3f4f6;
+ color: #6b7280;
+}
+
.btn-edit, .btn-save, .btn-cancel {
margin-top: 15px;
padding: 8px 20px;
diff --git a/app/Domains/CostUnit/Routes/web.php b/app/Domains/CostUnit/Routes/web.php
index ed2400c..baffe20 100644
--- a/app/Domains/CostUnit/Routes/web.php
+++ b/app/Domains/CostUnit/Routes/web.php
@@ -2,11 +2,6 @@
use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\CostUnit\Controllers\OpenController;
-use App\Domains\UserManagement\Controllers\EmailVerificationController;
-use App\Domains\UserManagement\Controllers\LoginController;
-use App\Domains\UserManagement\Controllers\LogOutController;
-use App\Domains\UserManagement\Controllers\RegistrationController;
-use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -23,16 +18,10 @@ Route::middleware(IdentifyTenant::class)->group(function () {
});
- Route::get('/register', [RegistrationController::class, 'loginForm']);
- Route::get('/register/verifyEmail', [EmailVerificationController::class, 'verifyEmailForm']);
-
- Route::get('/reset-password', [ResetPasswordController::class, 'resetPasswordForm']);
-
- route::get('/logout', LogOutController::class);
- route::post('/login', [LoginController::class, 'doLogin']);
- route::get('/login', [LoginController::class, 'loginForm']);
-
+ // Anmeldung, Registrierung und Passwort-Reset gehören zur Domain UserManagement und sind dort
+ // definiert. Die Duplikate hier überschrieben die dortigen Routen -- unter anderem die benannte
+ // Route `login`, auf die Laravels `auth`-Middleware Gäste umleitet.
});
diff --git a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php
index 7a1ccef..d8a93fa 100644
--- a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php
+++ b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php
@@ -9,6 +9,7 @@ use App\Models\Tenant;
use App\RelationModels\EventEatingHabits;
use App\RelationModels\EventLocalGroups;
use App\RelationModels\EventPaymentMethods;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CreateEventCommand {
@@ -27,8 +28,13 @@ class CreateEventCommand {
}
- $event = Event::create([
- 'tenant' => app('tenant')->slug,
+ $tenant = app('tenant');
+
+ // Anlage als Ganzes: der Event-Teil der Rechnungsnummer wird unter der Sperre vergeben und muss
+ // im selben Zug geschrieben werden, sonst könnten zwei gleichzeitige Anlagen denselben erhalten.
+ $event = DB::transaction(function () use ($tenant): Event {
+ $event = Event::create([
+ 'tenant' => $tenant->slug,
'name' => $this->request->name,
'identifier' => Str::random(10),
'location' => $this->request->location,
@@ -49,12 +55,22 @@ class CreateEventCommand {
'support_flat' => 0,
// Umsatzsteuerliche Grundlage bei Anlage einfrieren (unveränderlich), damit Preisermittlung und
// spätere Rechnungen von späteren Tenant-Änderungen unberührt bleiben.
- 'tax_liable' => app('tenant')->tax_liable,
- 'vat_rate' => app('tenant')->vat_rate,
- 'vat_pricing_mode' => app('tenant')->vat_pricing_mode,
- 'tax_exemption_reason' => app('tenant')->tax_exemption_reason,
- 'tax_exemption_note' => app('tenant')->tax_exemption_note,
- ]);
+ 'tax_liable' => $tenant->tax_liable,
+ 'vat_rate' => $tenant->vat_rate,
+ 'vat_pricing_mode' => $tenant->vat_pricing_mode,
+ 'tax_exemption_reason' => $tenant->tax_exemption_reason,
+ 'tax_exemption_note' => $tenant->tax_exemption_note,
+ // Den Event-Teil der Rechnungsnummer einfrieren: Tenant-Präfix und Startdatum sind später
+ // änderbar, eine herausgegebene Rechnung würde sonst zu einer anderen Nummer gehören.
+ //
+ // Der Rechnungssteller wird bewusst NICHT mitkopiert -- er ist der Mandant und wird beim
+ // Erzeugen der Rechnung von dort gelesen, damit eine Korrektur an Name oder Anschrift auch
+ // auf bestehende Veranstaltungen wirkt.
+ 'invoice_key' => $this->nextInvoiceKey($tenant),
+ ]);
+
+ return $event;
+ });
if ($event !== null) {
EventEatingHabits::create([
@@ -90,4 +106,35 @@ class CreateEventCommand {
return $response;
}
+
+ /**
+ * Event-Teil der Rechnungsnummer, z.B. `WM-V-20260701`: Tenant-Präfix, Dokumentart „V" für
+ * Veranstaltung, dann ohne Trenner Jahr, Monat des Beginns und die laufende Nummer der
+ * Veranstaltungen dieses Tenants in diesem Monat.
+ *
+ * Gezählt wird über die bereits vergebenen Schlüssel desselben Monats. Die Sperre auf der
+ * Tenant-Zeile serialisiert gleichzeitige Anlagen; der Aufruf erfolgt innerhalb der
+ * Anlage-Transaktion, sodass die Sperre bis zum Schreiben des Events steht.
+ */
+ private function nextInvoiceKey(Tenant $tenant): string
+ {
+ // Jahr und Monat ohne Trenner; die laufende Nummer haengt unmittelbar daran. Alle drei Teile
+ // haben feste Laenge, der Schluessel bleibt dadurch eindeutig zerlegbar.
+ $month = $this->request->begin->format('Ym');
+ $prefix = sprintf('%s-V-%s', $tenant->invoice_prefix ?? strtoupper($tenant->slug), $month);
+
+ DB::table('tenants')->where('id', $tenant->id)->lockForUpdate()->first();
+
+ $used = DB::table('events')
+ ->where('tenant', $tenant->slug)
+ ->where('invoice_key', 'like', $prefix . '%')
+ ->pluck('invoice_key');
+
+ $highest = 0;
+ foreach ($used as $key) {
+ $highest = max($highest, (int) substr((string) $key, strlen($prefix)));
+ }
+
+ return $prefix . str_pad((string) ($highest + 1), 2, '0', STR_PAD_LEFT);
+ }
}
diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
index b0db8a3..fb9ee6d 100644
--- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php
+++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\ValueObjects\Age;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SignUpCommand {
@@ -43,15 +44,21 @@ class SignUpCommand {
$participantAge = new Age($this->request->birthday);
- $response->participant = $this->request->event->participants()->create(
+ // Anmeldung als Ganzes: die laufende Nummer (invoice_sequence) muss unter der Sperre vergeben und
+ // im selben Zug geschrieben werden, sonst könnten zwei gleichzeitige Anmeldungen dieselbe erhalten.
+ $response->participant = DB::transaction(function () use ($participantAge, $eatingHabit, $paymentOptions, $participationOptionRows) {
+ $participant = $this->request->event->participants()->create(
[
'tenant' => $this->request->event->tenant,
'user_id' => $this->request->user_id,
'identifier' => Str::random(10),
+ 'invoice_sequence' => $this->nextInvoiceSequence(),
'firstname' => $this->request->firstname,
'lastname' => $this->request->lastname,
'nickname' => $this->request->nickname,
'participation_type' => $this->request->participationType,
+ 'fee_type' => $this->request->feeType,
+ 'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup->slug,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
@@ -90,15 +97,40 @@ class SignUpCommand {
'payment_options' => $paymentOptions,
'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]
- );
+ );
- $this->persistParticipationOptions($response->participant, $participationOptionRows);
- $this->persistAddons($response->participant);
+ $this->persistParticipationOptions($participant, $participationOptionRows);
+ $this->persistAddons($participant);
+
+ return $participant;
+ });
$response->success = true;
return $response;
}
+ /**
+ * Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der
+ * Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben
+ * Veranstaltung serialisiert werden; der Aufruf erfolgt innerhalb der Anmelde-Transaktion, sodass die
+ * Sperre bis zum Schreiben des Teilis steht.
+ *
+ * Abgemeldete Teilis behalten ihre Nummer, gezählt wird deshalb über MAX und nicht über COUNT.
+ */
+ private function nextInvoiceSequence(): int
+ {
+ DB::table('events')
+ ->where('id', $this->request->event->id)
+ ->lockForUpdate()
+ ->first();
+
+ $highest = DB::table('event_participants')
+ ->where('event_id', $this->request->event->id)
+ ->max('invoice_sequence');
+
+ return (int) $highest + 1;
+ }
+
/**
* Speichert die gewählten Zusätze (Event-Addons) als Snapshot-Zeilen (Titel/Preis/Betrag) für die spätere
* Rechnung. Die Beträge sind bereits in der Controller-Auflösung (`resolveAddons`) berechnet, inkl. USt.
diff --git a/app/Domains/Event/Actions/SignUp/SignUpRequest.php b/app/Domains/Event/Actions/SignUp/SignUpRequest.php
index cec782f..f92a49c 100644
--- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php
+++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php
@@ -50,6 +50,10 @@ class SignUpRequest {
public array $participationOptions = [],
/** Aufgelöste Zusätze (Event-Addons): je Eintrag [ key, title, price, flat, days, amount ]. */
public array $addonItems = [],
+ /** Gewählte Preisspalte: standard | reduced | solidarity. Wird für die Rechnung mitgeschrieben. */
+ public ?string $feeType = null,
+ /** Ob die 50-%-Geschwisterermäßigung in `amount` eingerechnet ist. */
+ public bool $siblingReduction = false,
) {
}
}
diff --git a/app/Domains/Event/Controllers/SignupController.php b/app/Domains/Event/Controllers/SignupController.php
index 9de5ea7..dfa98ee 100644
--- a/app/Domains/Event/Controllers/SignupController.php
+++ b/app/Domains/Event/Controllers/SignupController.php
@@ -150,6 +150,8 @@ class SignupController extends CommonController {
(array)($registrationData['paymentOptions'] ?? []),
(array)($registrationData['participationOptions'] ?? []),
$addonResolution['items'],
+ $registrationData['beitrag'],
+ $siblingReduction,
);
$signupCommand = new SignUpCommand($signupRequest);
diff --git a/app/Domains/Event/Views/Partials/ParticipantData.vue b/app/Domains/Event/Views/Partials/ParticipantData.vue
index 2180096..912f44e 100644
--- a/app/Domains/Event/Views/Partials/ParticipantData.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantData.vue
@@ -1,7 +1,11 @@
@@ -19,7 +32,7 @@ const model = defineModel()
language: 'de',
language_url: '/tinymce/langs/de.js',
ui_mode: 'split',
-
+ convert_urls: props.convertUrls,
}"
/>
diff --git a/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php b/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
index 10cce5b..2c33a83 100644
--- a/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
+++ b/database/migrations/2026_08_06_170030_default_tax_exemption_reason_jugendhilfe.php
@@ -1,20 +1,28 @@
string('tax_exemption_reason')->nullable()->default('ustg_4_25a')->change();
+ });
}
public function down(): void
{
- DB::statement('ALTER TABLE tenants ALTER COLUMN tax_exemption_reason DROP DEFAULT');
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->string('tax_exemption_reason')->nullable()->default(null)->change();
+ });
}
};
diff --git a/database/migrations/2026_08_27_140010_add_invoice_fields_to_tenants.php b/database/migrations/2026_08_27_140010_add_invoice_fields_to_tenants.php
new file mode 100644
index 0000000..e702e00
--- /dev/null
+++ b/database/migrations/2026_08_27_140010_add_invoice_fields_to_tenants.php
@@ -0,0 +1,55 @@
+string('address_1')->nullable()->after('name');
+ $table->string('address_2')->nullable()->after('address_1');
+ $table->string('address_3')->nullable()->after('address_2');
+ $table->string('phone')->nullable()->after('email_finance');
+ $table->string('tax_number')->nullable()->after('vat_pricing_mode');
+ $table->string('vat_id')->nullable()->after('tax_number');
+ $table->string('invoice_prefix')->nullable()->after('vat_id');
+ });
+
+ // Bestands-Tenants bekommen den großgeschriebenen Slug als Default-Präfix (wm -> WM).
+ foreach (DB::table('tenants')->select('id', 'slug')->get() as $tenant) {
+ DB::table('tenants')
+ ->where('id', $tenant->id)
+ ->update(['invoice_prefix' => strtoupper($tenant->slug)]);
+ }
+
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->unique('invoice_prefix');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropUnique(['invoice_prefix']);
+ $table->dropColumn([
+ 'address_1',
+ 'address_2',
+ 'address_3',
+ 'phone',
+ 'tax_number',
+ 'vat_id',
+ 'invoice_prefix',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_27_140020_add_invoice_snapshot_to_events.php b/database/migrations/2026_08_27_140020_add_invoice_snapshot_to_events.php
new file mode 100644
index 0000000..6101fa2
--- /dev/null
+++ b/database/migrations/2026_08_27_140020_add_invoice_snapshot_to_events.php
@@ -0,0 +1,108 @@
+string('invoice_key')->nullable()->unique();
+
+ $table->string('invoice_sender_name')->nullable();
+ $table->string('invoice_sender_address_1')->nullable();
+ $table->string('invoice_sender_address_2')->nullable();
+ $table->string('invoice_sender_address_3')->nullable();
+ $table->string('invoice_sender_postcode')->nullable();
+ $table->string('invoice_sender_city')->nullable();
+ $table->string('invoice_sender_email')->nullable();
+ $table->string('invoice_sender_phone')->nullable();
+ $table->string('invoice_sender_tax_number')->nullable();
+ $table->string('invoice_sender_vat_id')->nullable();
+ });
+
+ $this->backfill();
+ }
+
+ /**
+ * Bestandsevents nachträglich bestücken: Absenderdaten aus dem heutigen Tenant, `invoice_key` je
+ * Tenant und Monat nach `start_date`, dann `id` durchnummeriert.
+ */
+ private function backfill(): void
+ {
+ $tenants = DB::table('tenants')->get()->keyBy('slug');
+ $counters = [];
+
+ $events = DB::table('events')
+ ->select('id', 'tenant', 'start_date')
+ ->orderBy('start_date')
+ ->orderBy('id')
+ ->get();
+
+ foreach ($events as $event) {
+ $tenant = $tenants->get($event->tenant);
+ if ($tenant === null || $event->start_date === null) {
+ continue;
+ }
+
+ $month = date('Y-m', strtotime($event->start_date));
+ $bucket = $event->tenant . '|' . $month;
+ $counters[$bucket] = ($counters[$bucket] ?? 0) + 1;
+
+ DB::table('events')->where('id', $event->id)->update([
+ // $month ist bereits "2026-07" -> ergibt "WM-V-2026-07-01"
+ 'invoice_key' => sprintf(
+ '%s-V-%s-%s',
+ $tenant->invoice_prefix,
+ $month,
+ str_pad((string) $counters[$bucket], 2, '0', STR_PAD_LEFT)
+ ),
+ 'invoice_sender_name' => $tenant->name,
+ 'invoice_sender_address_1' => $tenant->address_1,
+ 'invoice_sender_address_2' => $tenant->address_2,
+ 'invoice_sender_address_3' => $tenant->address_3,
+ 'invoice_sender_postcode' => $tenant->postcode,
+ 'invoice_sender_city' => $tenant->city,
+ 'invoice_sender_email' => $tenant->email,
+ 'invoice_sender_phone' => $tenant->phone,
+ 'invoice_sender_tax_number' => $tenant->tax_number,
+ 'invoice_sender_vat_id' => $tenant->vat_id,
+ ]);
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropUnique(['invoice_key']);
+ $table->dropColumn([
+ 'invoice_key',
+ 'invoice_sender_name',
+ 'invoice_sender_address_1',
+ 'invoice_sender_address_2',
+ 'invoice_sender_address_3',
+ 'invoice_sender_postcode',
+ 'invoice_sender_city',
+ 'invoice_sender_email',
+ 'invoice_sender_phone',
+ 'invoice_sender_tax_number',
+ 'invoice_sender_vat_id',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_27_140030_add_invoice_fields_to_event_participants.php b/database/migrations/2026_08_27_140030_add_invoice_fields_to_event_participants.php
new file mode 100644
index 0000000..fae3797
--- /dev/null
+++ b/database/migrations/2026_08_27_140030_add_invoice_fields_to_event_participants.php
@@ -0,0 +1,61 @@
+unsignedInteger('invoice_sequence')->nullable()->after('identifier');
+ $table->string('fee_type')->nullable()->after('participation_type');
+ $table->boolean('sibling_reduction')->default(false)->after('fee_type');
+ });
+
+ $this->backfillSequences();
+
+ Schema::table('event_participants', function (Blueprint $table) {
+ $table->unique(['event_id', 'invoice_sequence']);
+ });
+ }
+
+ /** Bestandsanmeldungen je Event nach `id` durchnummerieren. */
+ private function backfillSequences(): void
+ {
+ $counters = [];
+
+ $participants = DB::table('event_participants')
+ ->select('id', 'event_id')
+ ->orderBy('event_id')
+ ->orderBy('id')
+ ->get();
+
+ foreach ($participants as $participant) {
+ $counters[$participant->event_id] = ($counters[$participant->event_id] ?? 0) + 1;
+
+ DB::table('event_participants')
+ ->where('id', $participant->id)
+ ->update(['invoice_sequence' => $counters[$participant->event_id]]);
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_participants', function (Blueprint $table) {
+ $table->dropUnique(['event_id', 'invoice_sequence']);
+ $table->dropColumn(['invoice_sequence', 'fee_type', 'sibling_reduction']);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_27_150010_create_document_templates.php b/database/migrations/2026_08_27_150010_create_document_templates.php
new file mode 100644
index 0000000..6f2f8bd
--- /dev/null
+++ b/database/migrations/2026_08_27_150010_create_document_templates.php
@@ -0,0 +1,36 @@
+id();
+ $table->string('document_type');
+ $table->string('block');
+ $table->longText('content')->nullable();
+ $table->integer('sort_order')->default(1);
+ $table->boolean('editable')->default(true);
+ $table->timestamps();
+
+ $table->unique(['document_type', 'block']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('document_templates');
+ }
+};
diff --git a/database/migrations/2026_08_27_150020_create_document_assets.php b/database/migrations/2026_08_27_150020_create_document_assets.php
new file mode 100644
index 0000000..caadc26
--- /dev/null
+++ b/database/migrations/2026_08_27_150020_create_document_assets.php
@@ -0,0 +1,34 @@
+id();
+ $table->string('name')->unique();
+ $table->string('label')->nullable();
+ $table->string('mime');
+ $table->longText('data');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('document_assets');
+ }
+};
diff --git a/database/migrations/2026_09_02_140010_add_invoice_sender_name_to_tenants.php b/database/migrations/2026_09_02_140010_add_invoice_sender_name_to_tenants.php
new file mode 100644
index 0000000..8410ef1
--- /dev/null
+++ b/database/migrations/2026_09_02_140010_add_invoice_sender_name_to_tenants.php
@@ -0,0 +1,28 @@
+string('invoice_sender_name')->nullable()->after('name');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropColumn('invoice_sender_name');
+ });
+ }
+};
diff --git a/database/migrations/2026_09_02_140020_drop_invoice_sender_snapshot_from_events.php b/database/migrations/2026_09_02_140020_drop_invoice_sender_snapshot_from_events.php
new file mode 100644
index 0000000..6e33886
--- /dev/null
+++ b/database/migrations/2026_09_02_140020_drop_invoice_sender_snapshot_from_events.php
@@ -0,0 +1,51 @@
+ */
+ private const array COLUMNS = [
+ 'invoice_sender_name',
+ 'invoice_sender_address_1',
+ 'invoice_sender_address_2',
+ 'invoice_sender_address_3',
+ 'invoice_sender_postcode',
+ 'invoice_sender_city',
+ 'invoice_sender_email',
+ 'invoice_sender_phone',
+ 'invoice_sender_tax_number',
+ 'invoice_sender_vat_id',
+ ];
+
+ public function up(): void
+ {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropColumn(self::COLUMNS);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('events', function (Blueprint $table) {
+ foreach (self::COLUMNS as $column) {
+ $table->string($column)->nullable();
+ }
+ });
+ }
+};
diff --git a/database/migrations/2026_09_02_140030_compact_invoice_key_format.php b/database/migrations/2026_09_02_140030_compact_invoice_key_format.php
new file mode 100644
index 0000000..dfbd093
--- /dev/null
+++ b/database/migrations/2026_09_02_140030_compact_invoice_key_format.php
@@ -0,0 +1,42 @@
+ Rechnungsnummer WM-V-2026-07-01-0005
+ * nachher WM-V-20260701 -> Rechnungsnummer WM-V-20260701-0005
+ *
+ * Die Rechnungsnummer selbst wird nirgends gespeichert, sondern bei jedem Abruf aus `invoice_key`
+ * und `event_participants.invoice_sequence` zusammengesetzt. Die Umstellung hier wirkt daher
+ * unmittelbar auf alle Rechnungen; alte und neue Veranstaltungen bleiben im selben Format.
+ */
+return new class extends Migration {
+ public function up(): void
+ {
+ $this->rewrite('/^(.*-V-)(\d{4})-(\d{2})-(\d{2})$/', '$1$2$3$4');
+ }
+
+ public function down(): void
+ {
+ $this->rewrite('/^(.*-V-)(\d{4})(\d{2})(\d{2})$/', '$1$2-$3-$4');
+ }
+
+ private function rewrite(string $pattern, string $replacement): void
+ {
+ $events = DB::table('events')
+ ->select('id', 'invoice_key')
+ ->whereNotNull('invoice_key')
+ ->get();
+
+ foreach ($events as $event) {
+ $rewritten = preg_replace($pattern, $replacement, (string) $event->invoice_key);
+
+ if ($rewritten !== null && $rewritten !== $event->invoice_key) {
+ DB::table('events')->where('id', $event->id)->update(['invoice_key' => $rewritten]);
+ }
+ }
+ }
+};
diff --git a/database/seed-scripts/participant-invoice-template.sql b/database/seed-scripts/participant-invoice-template.sql
new file mode 100644
index 0000000..8bd9fd2
--- /dev/null
+++ b/database/seed-scripts/participant-invoice-template.sql
@@ -0,0 +1,160 @@
+-- Standard-Vorlage für die Teilnehmer-Rechnung.
+--
+-- Bewusst ein manuelles Skript und keine Migration: der Vorlageninhalt wird über die Admin-Oberfläche
+-- gepflegt und soll von einem Release nicht überschrieben werden. Einspielen also nur bei der
+-- Erstinstallation oder wenn die Vorlage bewusst zurückgesetzt werden soll.
+--
+-- docker exec -i mareike-mareike-db mysql -u -p < database/seed-scripts/participant-invoice-template.sql
+--
+-- Die Bilder liegen als base64 in der Datenbank, damit ein Release sie nicht überschreibt und sie mit
+-- jedem Dump mitwandern. Referenziert werden sie in der Vorlage als {asset:name}.
+--
+-- Zum Asset "edge": der gelbe Randstreifen mit Knick aus dem BdP-Erscheinungsbild. Mit GD gezeichnet
+-- (16 mm breit, volle Seitenhöhe, Innenkante von 10 mm oben auf 3 mm bei 198 mm, dann auf 16 mm
+-- auslaufend). Als PNG und nicht als SVG, weil ohne svg-lib von dompdf kommentarlos ignoriert.
+
+DELETE FROM document_templates WHERE document_type = 'participant_invoice';
+
+INSERT INTO document_templates (document_type, block, content, sort_order, editable, created_at, updated_at) VALUES
+ ('participant_invoice', 'layout', '
+
+
',
+ ]);
+
+ $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]);
+
+ // Beides bleibt wörtlich stehen -- nichts davon wird ausgewertet.
+ $this->assertStringContainsString('{{ 7*7 }}', $html);
+ $this->assertStringContainsString('@php echo "ausgefuehrt"; @endphp', $html);
+ $this->assertStringNotContainsString('49', $html);
+ $this->assertStringNotContainsString('>ausgefuehrt<', $html);
+ }
+
+ public function test_a_bare_data_uri_is_stripped_from_the_css(): void
+ {
+ // dompdf lagert Data-URIs im CSS nur aus, wenn ihnen ( " oder ' vorausgeht. Ein bar stehendes
+ // bleibt liegen und treibt seinen Parser in quadratische Laufzeit -- unter FPM stirbt der
+ // Worker am Zeitlimit und die Anfrage endet in einem 502.
+ DocumentAsset::create(['name' => 'logo', 'mime' => 'image/png', 'data' => 'RUlORFJVQ0s']);
+
+ DocumentTemplate::where('block', DocumentTemplate::BLOCK_STYLE)->update([
+ 'content' => '.a { color: red; }{asset:logo}{asset:logo}',
+ ]);
+
+ $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]);
+
+ $this->assertStringContainsString('.a { color: red; }', $html);
+ $this->assertStringNotContainsString('data:image/png', $html);
+ // Entscheidend ist der Rumpf: das Semikolon gehört zum Data-URI. Eine Begrenzung, die dort
+ // aufhört, entfernt nur den Präfix und lässt das Base64 stehen -- der Parser bremst weiter.
+ $this->assertStringNotContainsString('RUlORFJVQ0s', $html);
+ $this->assertStringNotContainsString('base64', $html);
+ }
+
+ public function test_a_data_uri_inside_url_is_kept_in_the_css(): void
+ {
+ DocumentAsset::create(['name' => 'logo', 'mime' => 'image/png', 'data' => 'QUJD']);
+
+ DocumentTemplate::where('block', DocumentTemplate::BLOCK_STYLE)->update([
+ 'content' => '.a { background: url("{asset:logo}"); }',
+ ]);
+
+ $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]);
+
+ // Korrekt eingebettet ist es für dompdf kein Problem und bleibt erhalten.
+ $this->assertStringContainsString('url("data:image/png;base64,QUJD")', $html);
+ }
+
+ public function test_unknown_asset_resolves_to_nothing(): void
+ {
+ DocumentAsset::create([
+ 'name' => 'logo',
+ 'mime' => 'image/png',
+ 'data' => 'QUJD',
+ ]);
+
+ DocumentTemplate::where('block', 'subject')->update([
+ 'content' => '',
+ ]);
+
+ $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)->render([]);
+
+ $this->assertStringContainsString('src="data:image/png;base64,QUJD"', $html);
+ $this->assertStringContainsString('src=""', $html);
+ $this->assertStringNotContainsString('{asset:', $html);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Rechnungssteller
+ |--------------------------------------------------------------------------
+ */
+
+ private function senderName(EventParticipant $participant): string
+ {
+ $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant));
+
+ return new ReflectionMethod($command, 'buildTokens')
+ ->invoke($command, 'WM-V-20260701-0005', [], 300.0)['sender_name'];
+ }
+
+ public function test_without_an_own_entry_the_tenant_name_is_the_sender(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent());
+
+ $this->assertSame('Wilde Möhre', $this->senderName($participant));
+ }
+
+ public function test_an_own_invoice_sender_name_wins(): void
+ {
+ $this->tenant->update(['invoice_sender_name' => 'BdP Landesverband Sachsen e.V.']);
+
+ $participant = $this->makeParticipant($this->makeEvent());
+
+ $this->assertSame('BdP Landesverband Sachsen e.V.', $this->senderName($participant->fresh()));
+ }
+
+ public function test_a_blank_entry_falls_back_to_the_tenant_name(): void
+ {
+ // Aus dem Formular kommt ein Leerstring, nicht null.
+ $this->tenant->update(['invoice_sender_name' => ' ']);
+
+ $participant = $this->makeParticipant($this->makeEvent());
+
+ $this->assertSame('Wilde Möhre', $this->senderName($participant->fresh()));
+ }
+
+ public function test_correcting_the_sender_reaches_existing_events(): void
+ {
+ // Der Fall, der vorher nicht funktioniert hat: der Absender wurde bei der Event-Anlage
+ // eingefroren, eine spätere Korrektur wirkte deshalb auf bestehende Veranstaltungen nie.
+ $participant = $this->makeParticipant($this->makeEvent());
+
+ $this->tenant->update([
+ 'invoice_sender_name' => 'BdP Landesverband Sachsen e.V.',
+ 'address_1' => 'Bernhard-Göring-Str. 152',
+ ]);
+
+ $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant->fresh()));
+ $tokens = new ReflectionMethod($command, 'buildTokens')
+ ->invoke($command, 'WM-V-20260701-0005', [], 300.0);
+
+ $this->assertSame('BdP Landesverband Sachsen e.V.', $tokens['sender_name']);
+ $this->assertSame('Bernhard-Göring-Str. 152', $tokens['sender_address_1']);
+ }
+
+ public function test_the_tax_basis_stays_frozen_on_the_event(): void
+ {
+ // Gegenprobe zur Abgrenzung: die Steuergrundlage erklärt, wie der Preis zustande kam, und
+ // folgt dem Mandanten ausdrücklich NICHT.
+ $event = $this->makeEvent(['tax_liable' => true, 'vat_rate' => 19]);
+
+ $this->tenant->update(['tax_liable' => false, 'vat_rate' => 0]);
+
+ $this->assertTrue($event->fresh()->tax_liable);
+ $this->assertSame(19, $event->fresh()->vat_rate);
+ }
+
+ public function test_token_catalogue_matches_what_the_command_actually_provides(): void
+ {
+ // Der Katalog speist die Platzhalter-Liste und die Vorschau in der Vorlagen-Verwaltung. Liefe er
+ // gegenüber dem Command auseinander, würde die Verwaltung Platzhalter anbieten, die auf der echten
+ // Rechnung leer bleiben -- oder umgekehrt welche verschweigen.
+ $participant = $this->makeParticipant($this->makeEvent());
+ $command = new CreateParticipantInvoiceCommand(new CreateParticipantInvoiceRequest($participant));
+
+ $actual = array_keys(
+ new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005', [], 300.0)
+ );
+
+ sort($actual);
+ $documented = ParticipantInvoiceTokens::names();
+ sort($documented);
+
+ $this->assertSame($documented, $actual);
+ }
+
+ public function test_conditional_sections_drop_when_the_value_is_missing(): void
+ {
+ DocumentTemplate::where('block', 'subject')->update([
+ 'content' => '{if:sender_phone}Telefon: {sender_phone}{/if:sender_phone}'
+ . '{if:sender_city}Ort: {sender_city}{/if:sender_city}',
+ ]);
+
+ $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
+ ->render(['sender_phone' => '', 'sender_city' => 'Stadt']);
+
+ $this->assertStringNotContainsString('Telefon:', $html);
+ $this->assertStringContainsString('Ort: Stadt', $html);
+ }
+}
diff --git a/tests/Feature/TenantTaxInformationTest.php b/tests/Feature/TenantTaxInformationTest.php
index 05398c4..ca9d9cb 100644
--- a/tests/Feature/TenantTaxInformationTest.php
+++ b/tests/Feature/TenantTaxInformationTest.php
@@ -80,11 +80,15 @@ class TenantTaxInformationTest extends TestCase
public function test_invalid_exemption_reason_fails(): void
{
+ // Neue Tenants tragen den DB-Default (Jugendhilfe); verglichen wird deshalb gegen den Stand
+ // vor der Aktion, nicht gegen null.
+ $before = $this->tenant->fresh()->tax_exemption_reason;
+
$response = $this->runAction(taxLiable: false, vatRate: 0, reason: 'not_a_reason');
$this->assertFalse($response->success);
- // Unbekannter Slug wird nicht aufgelöst (null) → kein Speichern, Wert bleibt unverändert.
- $this->assertNull($this->tenant->fresh()->tax_exemption_reason);
+ // Unbekannter Slug wird nicht aufgelöst → kein Speichern, Wert bleibt unverändert.
+ $this->assertSame($before, $this->tenant->fresh()->tax_exemption_reason);
}
public function test_custom_reason_requires_note(): void
diff --git a/tests/Unit/DateRangeTest.php b/tests/Unit/DateRangeTest.php
new file mode 100644
index 0000000..7463483
--- /dev/null
+++ b/tests/Unit/DateRangeTest.php
@@ -0,0 +1,43 @@
+assertSame(1, DateRange::inclusiveDays($day, $day));
+ }
+
+ public function test_both_ends_are_counted(): void
+ {
+ // An- und Abreisetag zählen beide mit.
+ $this->assertSame(2, DateRange::inclusiveDays(new DateTime('2026-07-16'), new DateTime('2026-07-17')));
+ $this->assertSame(5, DateRange::inclusiveDays(new DateTime('2026-07-16'), new DateTime('2026-07-20')));
+ }
+
+ public function test_the_order_of_the_arguments_does_not_matter(): void
+ {
+ $early = new DateTime('2026-07-16');
+ $late = new DateTime('2026-07-20');
+
+ $this->assertSame(
+ DateRange::inclusiveDays($early, $late),
+ DateRange::inclusiveDays($late, $early)
+ );
+ }
+
+ public function test_month_and_year_boundaries_are_handled(): void
+ {
+ $this->assertSame(3, DateRange::inclusiveDays(new DateTime('2026-07-30'), new DateTime('2026-08-01')));
+ $this->assertSame(3, DateRange::inclusiveDays(new DateTime('2026-12-31'), new DateTime('2027-01-02')));
+ // 2028 ist ein Schaltjahr.
+ $this->assertSame(2, DateRange::inclusiveDays(new DateTime('2028-02-28'), new DateTime('2028-02-29')));
+ }
+}
diff --git a/tests/Unit/EventPaymentModuleRegistryTest.php b/tests/Unit/EventPaymentModuleRegistryTest.php
index a038d0f..57e0047 100644
--- a/tests/Unit/EventPaymentModuleRegistryTest.php
+++ b/tests/Unit/EventPaymentModuleRegistryTest.php
@@ -55,10 +55,9 @@ class EventPaymentModuleRegistryTest extends TestCase
// Überweisung: keine payer-seitigen Eingaben.
$this->assertSame([], (new AccountTransferPaymentModule())->getParticipantOptions());
- // Sonstiges: exemplarische Teilnehmer-Eingaben (Betrag + Passend-Checkbox).
- $undefined = (new UndefinedPaymentModule())->getParticipantOptions();
- $this->assertSame(['amount_brought', 'has_exact_amount'], array_column($undefined, 'name'));
- $this->assertSame('checkbox', $undefined[1]['type']);
+ // Sonstiges (Barzahlung vor Ort): ebenfalls keine payer-seitigen Eingaben -- die Zahlungsart
+ // beschreibt sich allein über den vom Veranstalter gepflegten Freitext.
+ $this->assertSame([], (new UndefinedPaymentModule())->getParticipantOptions());
}
public function test_participant_option_helpers(): void
diff --git a/tests/Unit/TextTest.php b/tests/Unit/TextTest.php
new file mode 100644
index 0000000..e4d857f
--- /dev/null
+++ b/tests/Unit/TextTest.php
@@ -0,0 +1,30 @@
+assertNull(Text::nullIfBlank(null));
+ $this->assertNull(Text::nullIfBlank(''));
+ $this->assertNull(Text::nullIfBlank(' '));
+ $this->assertNull(Text::nullIfBlank("\t\n"));
+ }
+
+ public function test_content_is_kept_and_trimmed(): void
+ {
+ $this->assertSame('Musterweg 1', Text::nullIfBlank(' Musterweg 1 '));
+ $this->assertSame('WM', Text::nullIfBlank('WM'));
+ }
+
+ public function test_a_zero_is_content_not_emptiness(): void
+ {
+ // "0" ist falsy -- eine naive Prüfung mit `empty()` würde die Angabe verschlucken.
+ $this->assertSame('0', Text::nullIfBlank('0'));
+ }
+}
From 6a183d6498af7c71e8df82114bebb0c32c2173c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Thu, 3 Sep 2026 21:23:26 +0200
Subject: [PATCH 03/11] Creating Participation refunds
---
.gitignore | 3 +
.../DocumentTemplatesGetController.php | 41 +-
.../DocumentTemplatesPreviewController.php | 13 +-
.../DocumentTemplatesUpdateController.php | 12 +-
app/Domains/Admin/Views/DocumentTemplates.vue | 62 +-
.../Event/Views/Partials/ParticipantData.vue | 13 +
.../Event/Views/Partials/ParticipantsList.vue | 198 ++++-
.../AcceptRefund/AcceptRefundCommand.php | 121 +++
.../AcceptRefund/AcceptRefundRequest.php | 17 +
.../AcceptRefund/AcceptRefundResponse.php | 17 +
.../CancelRefund/CancelRefundCommand.php | 45 +
.../CancelRefund/CancelRefundRequest.php | 13 +
.../CancelRefund/CancelRefundResponse.php | 10 +
.../CreateRefundDocumentCommand.php | 247 ++++++
.../CreateRefundDocumentRequest.php | 13 +
.../CreateRefundDocumentResponse.php | 16 +
.../ReleaseRefund/ReleaseRefundCommand.php | 134 +++
.../ReleaseRefund/ReleaseRefundRequest.php | 17 +
.../ReleaseRefund/ReleaseRefundResponse.php | 14 +
.../Controllers/AcceptRefundController.php | 37 +
.../Controllers/CancelRefundController.php | 30 +
.../Controllers/RefundDocumentController.php | 32 +
.../Controllers/RefundPageController.php | 61 ++
.../Controllers/ReleaseRefundController.php | 38 +
.../ParticipantRefundTokens.php | 117 +++
app/Domains/ParticipantRefund/Routes/api.php | 24 +
app/Domains/ParticipantRefund/Routes/web.php | 10 +
.../ParticipantRefund/Views/RefundPage.vue | 249 ++++++
app/Enumerations/RefundReason.php | 65 ++
.../RefundAcceptedMail.php | 73 ++
.../RefundReleasedMail.php | 59 ++
app/Models/DocumentTemplate.php | 4 +-
app/Models/DocumentTypeCatalog.php | 125 +++
app/Models/ParticipantRefund.php | 105 +++
app/Providers/GlobalDataProvider.php | 6 +
.../ParticipantRefundRepository.php | 41 +
app/Resources/EventParticipantResource.php | 18 +
app/Resources/ParticipantRefundResource.php | 37 +
app/Scopes/CommonController.php | 3 +
app/Support/Iban.php | 81 ++
composer.json | 4 +
...026_09_03_140010_create_refund_reasons.php | 63 ++
...9_03_140020_create_participant_refunds.php | 60 ++
...d_participant_refund_confirmation_text.php | 43 +
.../participant-refund-template.sql | 134 +++
docker/Dockerfile | 8 +-
.../emails/events/refund_accepted.blade.php | 44 +
.../emails/events/refund_released.blade.php | 51 ++
routes/web.php | 3 +
storage/temp/Eigenbeleg.pdf | Bin 0 -> 165355 bytes
storage/temp/Erstattungsbeleg-Vorschau.pdf | Bin 0 -> 189109 bytes
tests/Feature/DocumentTemplateAdminTest.php | 106 +++
tests/Feature/ParticipantRefundTest.php | 797 ++++++++++++++++++
tests/Feature/RefundDocumentTest.php | 434 ++++++++++
tests/Unit/IbanTest.php | 59 ++
55 files changed, 3985 insertions(+), 42 deletions(-)
create mode 100644 app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
create mode 100644 app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
create mode 100644 app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundResponse.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CancelRefund/CancelRefundCommand.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CancelRefund/CancelRefundRequest.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CancelRefund/CancelRefundResponse.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
create mode 100644 app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentResponse.php
create mode 100644 app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
create mode 100644 app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
create mode 100644 app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundResponse.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/CancelRefundController.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/RefundPageController.php
create mode 100644 app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
create mode 100644 app/Domains/ParticipantRefund/ParticipantRefundTokens.php
create mode 100644 app/Domains/ParticipantRefund/Routes/api.php
create mode 100644 app/Domains/ParticipantRefund/Routes/web.php
create mode 100644 app/Domains/ParticipantRefund/Views/RefundPage.vue
create mode 100644 app/Enumerations/RefundReason.php
create mode 100644 app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
create mode 100644 app/Mail/ParticipantRefundMails/RefundReleasedMail.php
create mode 100644 app/Models/DocumentTypeCatalog.php
create mode 100644 app/Models/ParticipantRefund.php
create mode 100644 app/Repositories/ParticipantRefundRepository.php
create mode 100644 app/Resources/ParticipantRefundResource.php
create mode 100644 app/Support/Iban.php
create mode 100644 database/migrations/2026_09_03_140010_create_refund_reasons.php
create mode 100644 database/migrations/2026_09_03_140020_create_participant_refunds.php
create mode 100644 database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php
create mode 100644 database/seed-scripts/participant-refund-template.sql
create mode 100644 resources/views/emails/events/refund_accepted.blade.php
create mode 100644 resources/views/emails/events/refund_released.blade.php
create mode 100644 storage/temp/Eigenbeleg.pdf
create mode 100644 storage/temp/Erstattungsbeleg-Vorschau.pdf
create mode 100644 tests/Feature/ParticipantRefundTest.php
create mode 100644 tests/Feature/RefundDocumentTest.php
create mode 100644 tests/Unit/IbanTest.php
diff --git a/.gitignore b/.gitignore
index 656cf1b..3f9cf0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ Homestead.json
Homestead.yaml
Thumbs.db
/docker-compose.yaml
+
+# HTML-Report von composer test:coverage
+/storage/coverage
diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php b/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php
index cb3bfb6..9b4b437 100644
--- a/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php
+++ b/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php
@@ -2,47 +2,38 @@
namespace App\Domains\Admin\Controllers;
-use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
use App\Models\DocumentAsset;
use App\Models\DocumentTemplate;
+use App\Models\DocumentTypeCatalog;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
class DocumentTemplatesGetController extends CommonController
{
- /** Blöcke, die Layout-HTML bzw. CSS enthalten und deshalb im Quelltext-Editor gepflegt werden. */
- private const array SOURCE_BLOCKS = [
- DocumentTemplate::BLOCK_LAYOUT,
- DocumentTemplate::BLOCK_STYLE,
- ];
-
- private const array BLOCK_LABELS = [
- DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst',
- DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)',
- 'header_sender_return' => 'Rücksendezeile',
- 'header_recipient' => 'Empfängeranschrift',
- 'emblem' => 'Emblem',
- 'logo' => 'Logo',
- 'sender_data' => 'Absenderangaben',
- 'subject' => 'Betreff und Referenzdaten',
- DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt',
- 'footer' => 'Grußformel und Fußnote',
- ];
-
- public function __invoke(): JsonResponse
+ public function __invoke(Request $request): JsonResponse
{
- $blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
+ $documentType = (string) $request->input('type', DocumentTypeCatalog::default());
+
+ if (!DocumentTypeCatalog::has($documentType)) {
+ abort(422, 'Unbekannte Dokumentart.');
+ }
+
+ $blocks = DocumentTemplate::forType($documentType)
->values()
->map(fn(DocumentTemplate $block): array => [
'block' => $block->block,
- 'label' => self::BLOCK_LABELS[$block->block] ?? $block->block,
+ 'label' => DocumentTypeCatalog::blockLabel($documentType, $block->block),
'content' => (string) $block->content,
'editable' => $block->editable,
- 'source' => in_array($block->block, self::SOURCE_BLOCKS, true),
+ 'source' => in_array($block->block, DocumentTypeCatalog::SOURCE_BLOCKS, true),
]);
return response()->json([
+ 'documentType' => $documentType,
+ 'documentTypes' => DocumentTypeCatalog::options(),
'blocks' => $blocks,
+ // Die Bilder gelten für alle Dokumentarten -- sie hängen nicht am Typ.
'assets' => DocumentAsset::orderBy('name')->get()->map(fn(DocumentAsset $asset): array => [
'name' => $asset->name,
'label' => $asset->label,
@@ -50,7 +41,7 @@ class DocumentTemplatesGetController extends CommonController
'token' => '{asset:' . $asset->name . '}',
'preview' => $asset->toDataUri(),
]),
- 'tokenGroups' => ParticipantInvoiceTokens::groups(),
+ 'tokenGroups' => DocumentTypeCatalog::tokenGroups($documentType),
]);
}
}
diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php b/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php
index fd0bab2..e03071d 100644
--- a/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php
+++ b/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php
@@ -2,8 +2,7 @@
namespace App\Domains\Admin\Controllers;
-use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
-use App\Models\DocumentTemplate;
+use App\Models\DocumentTypeCatalog;
use App\Providers\DocumentTemplateRenderProvider;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\Scopes\CommonController;
@@ -17,10 +16,16 @@ class DocumentTemplatesPreviewController extends CommonController
{
public function __invoke(Request $request): Response
{
+ $documentType = (string) $request->input('type', DocumentTypeCatalog::default());
+
+ if (!DocumentTypeCatalog::has($documentType)) {
+ abort(422, 'Unbekannte Dokumentart.');
+ }
+
$html = new DocumentTemplateRenderProvider(
- DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
+ $documentType,
(array) $request->input('blocks', []),
- )->render(ParticipantInvoiceTokens::sample());
+ )->render(DocumentTypeCatalog::sampleTokens($documentType));
return response(PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'), 200, [
'Content-Type' => 'application/pdf',
diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php b/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php
index afe53fa..876734d 100644
--- a/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php
+++ b/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php
@@ -4,7 +4,7 @@ namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateAction;
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateRequest;
-use App\Models\DocumentTemplate;
+use App\Models\DocumentTypeCatalog;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -13,8 +13,16 @@ class DocumentTemplatesUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
+ $documentType = (string) $request->input('type', DocumentTypeCatalog::default());
+
+ // Die Dokumentart bestimmt, welche Zeilen geschrieben werden -- ungeprüft weitergereicht wäre
+ // sie ein Weg, in beliebige Vorlagen zu schreiben.
+ if (!DocumentTypeCatalog::has($documentType)) {
+ abort(422, 'Unbekannte Dokumentart.');
+ }
+
$action = new UpdateDocumentTemplateAction(new UpdateDocumentTemplateRequest(
- documentType: DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
+ documentType: $documentType,
blocks: (array) $request->input('blocks', []),
));
diff --git a/app/Domains/Admin/Views/DocumentTemplates.vue b/app/Domains/Admin/Views/DocumentTemplates.vue
index df517d1..2bd66e3 100644
--- a/app/Domains/Admin/Views/DocumentTemplates.vue
+++ b/app/Domains/Admin/Views/DocumentTemplates.vue
@@ -12,6 +12,10 @@ const blocks = ref([])
const assets = ref([])
const tokenGroups = ref({})
+// Die Vorlagen sind nach Dokumentart getrennt; der Umschalter lädt jeweils deren Blöcke neu.
+const documentType = ref(null)
+const documentTypes = ref([])
+
const activeBlock = ref(null)
const saving = ref(false)
@@ -33,12 +37,15 @@ function selectBlock(block) {
onMounted(load)
async function load() {
- const data = await request('/api/v1/admin/document-templates', {method: 'GET'})
+ const query = documentType.value ? '?type=' + encodeURIComponent(documentType.value) : ''
+ const data = await request('/api/v1/admin/document-templates' + query, {method: 'GET'})
if (!data) {
toast.error('Die Vorlage konnte nicht geladen werden.')
return
}
+ documentType.value = data.documentType
+ documentTypes.value = data.documentTypes ?? []
blocks.value = data.blocks ?? []
assets.value = data.assets ?? []
tokenGroups.value = data.tokenGroups ?? {}
@@ -47,6 +54,15 @@ async function load() {
await refreshPreview()
}
+/** Beim Wechsel der Dokumentart alles neu holen -- Blöcke und Platzhalter sind je Art andere. */
+async function selectDocumentType(type) {
+ if (type === documentType.value) return
+
+ documentType.value = type
+ caret.value = null
+ await load()
+}
+
/** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */
function editableBlocks() {
return Object.fromEntries(
@@ -60,7 +76,7 @@ async function save() {
try {
const response = await request('/api/v1/admin/document-templates', {
method: 'POST',
- body: {blocks: editableBlocks()},
+ body: {type: documentType.value, blocks: editableBlocks()},
})
if (response?.status === 'success') {
@@ -83,7 +99,7 @@ async function refreshPreview() {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
- body: JSON.stringify({blocks: editableBlocks()}),
+ body: JSON.stringify({type: documentType.value, blocks: editableBlocks()}),
})
if (!response.ok) {
@@ -217,14 +233,28 @@ function onFileChosen(event) {
-
+
- Die Vorlage gilt für alle Mandanten; die eingesetzten Werte kommen je
- Rechnung aus dem jeweiligen Mandanten. Sie liegt in der Datenbank und wird von einem
+ Die Vorlagen gelten für alle Mandanten; die eingesetzten Werte kommen je
+ Dokument aus dem jeweiligen Mandanten. Sie liegen in der Datenbank und werden von einem
Update nicht überschrieben.
+
+
+
+
+
@@ -243,7 +273,7 @@ function onFileChosen(event) {
- Dieser Block wird beim Erzeugen der Rechnung aus den Daten der Anmeldung
+ Dieser Block wird beim Erzeugen des Dokuments aus den Daten der Anmeldung
zusammengesetzt und lässt sich nicht bearbeiten.
+ {{ props.participant.refund.amount }} – {{ props.participant.refund.reasonLabel }}
+
+ freigegeben am {{ props.participant.refund.releasedAt }}, wartet auf die
+ Bankverbindung des Teilis
+
+
+ bestätigt am {{ props.participant.refund.acceptedAt }}
+
+
+
diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue
index 0479493..ab84893 100644
--- a/app/Domains/Event/Views/Partials/ParticipantsList.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue
@@ -9,6 +9,7 @@ import {format} from "date-fns";
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";
const props = defineProps({
data: {
@@ -29,7 +30,7 @@ const props = defineProps({
const today = format(new Date(), "yyyy-MM-dd");
-const { request } = useAjax();
+const { request, download } = useAjax();
const searchTerms = reactive({});
const selectedStatuses = reactive({});
@@ -44,6 +45,18 @@ const mailCompose = ref(false);
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: ''});
+const refundReasons = ref([]);
+const refundSaving = ref(false);
+
+const selectedRefundReason = computed(
+ () => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
+);
defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete'])
@@ -292,6 +305,104 @@ async function execPartialPayment() {
openPartialPaymentDialogSwitch.value = false;
}
+/**
+ * Erstattung freigeben.
+ *
+ * Der Betrag ist mit dem vor, was der Teili gezahlt hat -- das ist der Regelfall; Abzüge trägt die
+ * Aktionsleitung von Hand ein.
+ */
+async function openRefundDialog(participant) {
+ showParticipant.value = participant;
+
+ refundForm.amount = participant.amountPaid?.short ?? '';
+ refundForm.reason = '';
+ refundForm.reasonNote = '';
+ refundErrors.amount = '';
+ refundErrors.reason = '';
+ refundErrors.reasonNote = '';
+
+ if (refundReasons.value.length === 0) {
+ const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
+ refundReasons.value = reasons ?? [];
+ }
+
+ openRefundDialogSwitch.value = true;
+}
+
+function validateRefund() {
+ const amount = Number((refundForm.amount ?? '').replace(',', '.'));
+ const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
+
+ refundErrors.amount = '';
+ refundErrors.reason = '';
+ refundErrors.reasonNote = '';
+
+ if (!refundForm.amount || !(amount > 0)) {
+ refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
+ } else if (amount > paid + 0.005) {
+ refundErrors.amount = 'Mehr als der gezahlte Beitrag kann nicht erstattet werden.';
+ }
+
+ if (!refundForm.reason) {
+ refundErrors.reason = 'Bitte wähle einen Grund aus.';
+ } else if (selectedRefundReason.value?.requiresNote && !refundForm.reasonNote.trim()) {
+ refundErrors.reasonNote = 'Bitte erläutere den Grund.';
+ }
+
+ return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote;
+}
+
+async function execRefund() {
+ if (!validateRefund() || refundSaving.value) {
+ return;
+ }
+
+ refundSaving.value = true;
+
+ try {
+ const data = await request('/api/v1/participant-refund/' + showParticipant.value.identifier + '/release', {
+ method: "POST",
+ body: {
+ amount: refundForm.amount,
+ reason: refundForm.reason,
+ reasonNote: refundForm.reasonNote,
+ },
+ });
+
+ if (data?.status === 'success') {
+ toast.success(data.message);
+ // Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
+ showParticipant.value.refund = data.refund;
+ openRefundDialogSwitch.value = false;
+ } else {
+ toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
+ }
+ } finally {
+ refundSaving.value = false;
+ }
+}
+
+async function execCancelRefund(participant) {
+ const data = await request('/api/v1/participant-refund/' + participant.refund.token + '/cancel', {
+ method: "POST",
+ });
+
+ if (data?.status === 'success') {
+ toast.success(data.message);
+ participant.refund = null;
+ } else {
+ toast.error(data?.message ?? 'Die Erstattung konnte nicht abgebrochen werden.');
+ }
+}
+
+async function downloadRefundDocument(participant) {
+ const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
+
+ if (!ok) {
+ toast.error('Der Beleg konnte nicht erstellt werden.');
+ }
+}
+
const mailToType = ref('')
const recipientIdentifier = ref('')
@@ -395,6 +506,27 @@ function mailToGroup(groupKey) {
E-Mail senden |
AbmeldenWieder anmelden
+
+
+
+ | Beitrag erstatten
+
+
+ | Erstattung offen: {{ participant.refund.amount }},
+ wartet auf Bankverbindung
+ Abbrechen
+
+
+
+ | Erstattet: {{ participant.refund.amount }}
+ Beleg
+
+
@@ -460,6 +592,48 @@ function mailToGroup(groupKey) {
+
+
+ {{ showParticipant?.fullname }} hat
+ {{ showParticipant?.amountPaid?.readable }} gezahlt. Nach der Freigabe erhält
+ der Teili eine E-Mail und trägt seine Bankverbindung selbst ein.
+
+
+
+
+
+ Euro
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Enumerations/RefundReason.php b/app/Enumerations/RefundReason.php
new file mode 100644
index 0000000..5932c6b
--- /dev/null
+++ b/app/Enumerations/RefundReason.php
@@ -0,0 +1,65 @@
+ 'boolean',
+ 'sort_order' => 'integer',
+ ];
+
+ /**
+ * Der Text, der auf dem Beleg unter „Grund" steht. Bei einem Grund, der einen Freitext verlangt,
+ * ist der hinterlegte Text der der Aktionsleitung.
+ */
+ public function documentText(?string $note = null): string
+ {
+ return $this->requires_note ? trim((string) $note) : (string) $this->document_text;
+ }
+
+ /**
+ * Optionen für das Frontend. `requiresNote` steuert dort das Freitextfeld.
+ *
+ * @return array
+ */
+ public static function options(): array
+ {
+ return self::orderBy('sort_order')->get()
+ ->map(static fn (self $reason): array => [
+ 'value' => $reason->slug,
+ 'label' => $reason->name,
+ 'requiresNote' => $reason->requires_note,
+ ])
+ ->all();
+ }
+}
diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
new file mode 100644
index 0000000..fa5ad5d
--- /dev/null
+++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
@@ -0,0 +1,73 @@
+participant->event()->first()->name
+ ),
+ );
+ }
+
+ public function content(): Content
+ {
+ $event = $this->participant->event()->first();
+
+ return new Content(
+ view: 'emails.events.refund_accepted',
+ with: [
+ 'name' => $this->participant->getNicename(),
+ 'eventTitle' => $event->name,
+ 'eventEmail' => $event->email,
+ 'amount' => $this->refund->amount?->toString() ?? '0,00 Euro',
+ 'reason' => $this->refund->reasonLabel(),
+ 'accountOwner' => $this->refund->account_owner,
+ 'accountIban' => Iban::format((string) $this->refund->account_iban),
+ 'hasDocument' => $this->pdfContent !== null,
+ ],
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function attachments(): array
+ {
+ if ($this->pdfContent === null) {
+ return [];
+ }
+
+ return [
+ Attachment::fromData(fn (): string => $this->pdfContent, $this->pdfFilename ?? 'Rueckerstattung.pdf')
+ ->withMime('application/pdf'),
+ ];
+ }
+}
diff --git a/app/Mail/ParticipantRefundMails/RefundReleasedMail.php b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php
new file mode 100644
index 0000000..605b694
--- /dev/null
+++ b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php
@@ -0,0 +1,59 @@
+participant->event()->first()->name
+ ),
+ );
+ }
+
+ public function content(): Content
+ {
+ $event = $this->participant->event()->first();
+
+ return new Content(
+ view: 'emails.events.refund_released',
+ with: [
+ 'name' => $this->participant->getNicename(),
+ 'eventTitle' => $event->name,
+ 'eventEmail' => $event->email,
+ 'amount' => $this->refund->amount?->toString() ?? '0,00 Euro',
+ 'reason' => $this->refund->reasonLabel(),
+ 'reasonNote' => $this->refund->reason_note,
+ // Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
+ 'link' => url('/rueckerstattung/' . $this->refund->token),
+ ],
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function attachments(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Models/DocumentTemplate.php b/app/Models/DocumentTemplate.php
index 58aef3e..16d9b48 100644
--- a/app/Models/DocumentTemplate.php
+++ b/app/Models/DocumentTemplate.php
@@ -19,13 +19,15 @@ class DocumentTemplate extends CommonModel
{
public const string TYPE_PARTICIPANT_INVOICE = 'participant_invoice';
+ public const string TYPE_PARTICIPANT_REFUND = 'participant_refund';
+
/** Seitengerüst -- enthält die `{block:...}`-Platzhalter und bestimmt damit die Anordnung. */
public const string BLOCK_LAYOUT = 'layout';
/** CSS des Dokuments. */
public const string BLOCK_STYLE = 'style';
- /** Der generierte Teil (Positionstabelle, Summen, Schlusssatz) -- nicht editierbar. */
+ /** Der Inhaltsblock. Bei der Rechnung generiert und gesperrt, beim Erstattungsbeleg pflegbar. */
public const string BLOCK_BODY = 'body';
protected $table = 'document_templates';
diff --git a/app/Models/DocumentTypeCatalog.php b/app/Models/DocumentTypeCatalog.php
new file mode 100644
index 0000000..b86c4c0
--- /dev/null
+++ b/app/Models/DocumentTypeCatalog.php
@@ -0,0 +1,125 @@
+
+ */
+ private const array COMMON_BLOCK_LABELS = [
+ DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst',
+ DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)',
+ 'header_sender_return' => 'Rücksendezeile',
+ 'header_recipient' => 'Empfängeranschrift',
+ 'emblem' => 'Emblem',
+ 'logo' => 'Logo',
+ 'sender_data' => 'Absenderangaben',
+ 'subject' => 'Betreff und Referenzdaten',
+ ];
+
+ /**
+ * @return array}>
+ */
+ public static function all(): array
+ {
+ return [
+ DocumentTemplate::TYPE_PARTICIPANT_INVOICE => [
+ 'label' => 'Teilnahmerechnung',
+ 'tokens' => ParticipantInvoiceTokens::class,
+ 'blockLabels' => self::COMMON_BLOCK_LABELS + [
+ DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt',
+ 'footer' => 'Grußformel und Fußnote',
+ ],
+ ],
+ DocumentTemplate::TYPE_PARTICIPANT_REFUND => [
+ 'label' => 'Erstattungsbeleg',
+ 'tokens' => ParticipantRefundTokens::class,
+ 'blockLabels' => self::COMMON_BLOCK_LABELS + [
+ DocumentTemplate::BLOCK_BODY => 'Erklärung und Angaben zur Erstattung',
+ 'footer' => 'Fußbereich',
+ ],
+ ],
+ ];
+ }
+
+ public static function has(string $documentType): bool
+ {
+ return array_key_exists($documentType, self::all());
+ }
+
+ /** Die Dokumentart, oder null wenn sie nicht im Katalog steht. */
+ public static function get(string $documentType): ?array
+ {
+ return self::all()[$documentType] ?? null;
+ }
+
+ public static function default(): string
+ {
+ return DocumentTemplate::TYPE_PARTICIPANT_INVOICE;
+ }
+
+ public static function blockLabel(string $documentType, string $block): string
+ {
+ return self::get($documentType)['blockLabels'][$block] ?? $block;
+ }
+
+ /**
+ * Platzhalter-Gruppen der Dokumentart -- für die Liste im Formular.
+ *
+ * @return array>
+ */
+ public static function tokenGroups(string $documentType): array
+ {
+ $tokens = self::get($documentType)['tokens'] ?? null;
+
+ return $tokens === null ? [] : $tokens::groups();
+ }
+
+ /**
+ * Beispielwerte der Dokumentart -- für die Vorschau.
+ *
+ * @return array
+ */
+ public static function sampleTokens(string $documentType): array
+ {
+ $tokens = self::get($documentType)['tokens'] ?? null;
+
+ return $tokens === null ? [] : $tokens::sample();
+ }
+
+ /**
+ * Die Auswahl für den Umschalter im Formular.
+ *
+ * @return array
+ */
+ public static function options(): array
+ {
+ $options = [];
+
+ foreach (self::all() as $type => $definition) {
+ $options[] = ['value' => $type, 'label' => $definition['label']];
+ }
+
+ return $options;
+ }
+}
diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php
new file mode 100644
index 0000000..89190ac
--- /dev/null
+++ b/app/Models/ParticipantRefund.php
@@ -0,0 +1,105 @@
+ AmountCast::class,
+ 'released_at' => 'datetime',
+ 'accepted_at' => 'datetime',
+ 'cancelled_at' => 'datetime',
+ ];
+
+ public function participant(): BelongsTo
+ {
+ return $this->belongsTo(EventParticipant::class, 'event_participant_id');
+ }
+
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ /**
+ * Der Grund als Stammdatensatz. Nicht `reason()`, weil das die Spalte `reason` verdecken würde.
+ */
+ public function reasonRelation(): BelongsTo
+ {
+ return $this->belongsTo(RefundReason::class, 'reason', 'slug');
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === self::STATUS_PENDING;
+ }
+
+ public function isAccepted(): bool
+ {
+ return $this->status === self::STATUS_ACCEPTED;
+ }
+
+ /** Der auf dem Beleg auszuweisende Grundtext -- bei Freitext-Gründen der Text der Aktionsleitung. */
+ public function reasonText(): string
+ {
+ return $this->reasonRelation()->first()?->documentText($this->reason_note) ?? '';
+ }
+
+ public function reasonLabel(): string
+ {
+ return (string) ($this->reasonRelation()->first()?->name ?? '');
+ }
+}
diff --git a/app/Providers/GlobalDataProvider.php b/app/Providers/GlobalDataProvider.php
index 5a3b08a..c9618a1 100644
--- a/app/Providers/GlobalDataProvider.php
+++ b/app/Providers/GlobalDataProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType;
+use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Models\AvailablePaymentMethod;
use App\Models\Tenant;
@@ -189,6 +190,11 @@ class GlobalDataProvider {
return $activeUsers;
}
+ /** Auswahl der Erstattungsgründe für den Dialog „Beitrag erstatten". */
+ public function getRefundReasons() : JsonResponse {
+ return response()->json(RefundReason::options());
+ }
+
public function getEventSettingData(Request $request) : JsonResponse {
return response()->json(
[
diff --git a/app/Repositories/ParticipantRefundRepository.php b/app/Repositories/ParticipantRefundRepository.php
new file mode 100644
index 0000000..008d1bc
--- /dev/null
+++ b/app/Repositories/ParticipantRefundRepository.php
@@ -0,0 +1,41 @@
+id)
+ ->where('status', ParticipantRefund::STATUS_PENDING)
+ ->first();
+ }
+
+ /**
+ * Der für die Anzeige maßgebliche Vorgang: der offene, sonst der zuletzt bestätigte. Abgebrochene
+ * Vorgänge bleiben außen vor -- für die Aktionsleitung sieht die Anmeldung danach aus wie vorher.
+ */
+ public function currentFor(EventParticipant $participant): ?ParticipantRefund
+ {
+ return ParticipantRefund::where('event_participant_id', $participant->id)
+ ->whereIn('status', [ParticipantRefund::STATUS_PENDING, ParticipantRefund::STATUS_ACCEPTED])
+ ->orderByDesc('id')
+ ->first();
+ }
+
+ /**
+ * Der Vorgang zu einem öffentlichen Link.
+ *
+ * Der Token ist die einzige Autorisierung -- dasselbe Modell wie bei /print-girocode/{identifier}.
+ * Der Mandant kommt über den globalen SiteScope aus dem Host der Anfrage: ein Token einer anderen
+ * Instanz findet hier nichts.
+ */
+ public function getByToken(string $token): ?ParticipantRefund
+ {
+ return ParticipantRefund::where('token', $token)->first();
+ }
+}
diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php
index 2dbbab3..1258e2a 100644
--- a/app/Resources/EventParticipantResource.php
+++ b/app/Resources/EventParticipantResource.php
@@ -8,6 +8,7 @@ use App\Enumerations\ParticipationType;
use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
+use App\Repositories\ParticipantRefundRepository;
use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -74,6 +75,10 @@ class EventParticipantResource extends JsonResource
// Numerisch, damit das Frontend rechnen bzw. auf "kostenlos" prüfen kann -- `value` oben ist
// ein Amount-Objekt und serialisiert nicht.
'amountExpectedValue' => $this->resource->amount?->getAmount() ?? 0.0,
+ 'amountPaidValue' => $this->resource->amount_paid?->getAmount() ?? 0.0,
+ // Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
+ // der letzte abgebrochen wurde.
+ 'refund' => $this->refund($request),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
@@ -106,4 +111,17 @@ class EventParticipantResource extends JsonResource
]
);
}
+
+ /**
+ * Der für die Anzeige maßgebliche Erstattungsvorgang.
+ *
+ * Abgebrochene Vorgänge bleiben außen vor: für die Aktionsleitung soll die Anmeldung danach
+ * aussehen wie vor der Freigabe.
+ */
+ private function refund($request): ?array
+ {
+ $refund = new ParticipantRefundRepository()->currentFor($this->resource);
+
+ return $refund?->toResource()->toArray($request);
+ }
}
diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php
new file mode 100644
index 0000000..433be0e
--- /dev/null
+++ b/app/Resources/ParticipantRefundResource.php
@@ -0,0 +1,37 @@
+resource->toArray()` als Basis wie in {@see EventParticipantResource}: die
+ * Bankverbindung darf nur dorthin, wo sie hingehört (Beleg und Auszahlung), nicht in jede
+ * Teilnehmerliste. Deshalb werden die Felder hier einzeln aufgeführt.
+ */
+class ParticipantRefundResource extends JsonResource
+{
+ public function __construct(ParticipantRefund $refund)
+ {
+ parent::__construct($refund);
+ }
+
+ public function toArray($request): array
+ {
+ return [
+ 'token' => $this->resource->token,
+ 'status' => $this->resource->status,
+ 'amount' => $this->resource->amount?->toString() ?? '0,00 Euro',
+ 'amountValue' => $this->resource->amount?->getAmount() ?? 0.0,
+ 'reason' => $this->resource->reason,
+ 'reasonLabel' => $this->resource->reasonLabel(),
+ 'reasonNote' => $this->resource->reason_note,
+ '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'),
+ ];
+ }
+}
diff --git a/app/Scopes/CommonController.php b/app/Scopes/CommonController.php
index 695b531..0969105 100644
--- a/app/Scopes/CommonController.php
+++ b/app/Scopes/CommonController.php
@@ -12,6 +12,7 @@ use App\Repositories\EventParticipantRepository;
use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
+use App\Repositories\ParticipantRefundRepository;
use App\Repositories\UserRepository;
abstract class CommonController {
@@ -24,6 +25,7 @@ abstract class CommonController {
protected InvoiceRepository $invoices;
protected EventRepository $events;
protected EventParticipantRepository $eventParticipants;
+ protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
@@ -36,6 +38,7 @@ abstract class CommonController {
$this->invoices = new InvoiceRepository();
$this->events = new EventRepository();
$this->eventParticipants = new EventParticipantRepository();
+ $this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
diff --git a/app/Support/Iban.php b/app/Support/Iban.php
new file mode 100644
index 0000000..9f4a15c
--- /dev/null
+++ b/app/Support/Iban.php
@@ -0,0 +1,81 @@
+
+ */
+ private const array LENGTHS = [
+ 'AD' => 24, 'AT' => 20, 'BE' => 16, 'BG' => 22, 'CH' => 21, 'CY' => 28, 'CZ' => 24,
+ 'DE' => 22, 'DK' => 18, 'EE' => 20, 'ES' => 24, 'FI' => 18, 'FR' => 27, 'GB' => 22,
+ 'GI' => 23, 'GR' => 27, 'HR' => 21, 'HU' => 28, 'IE' => 22, 'IS' => 26, 'IT' => 27,
+ 'LI' => 21, 'LT' => 20, 'LU' => 20, 'LV' => 21, 'MC' => 27, 'MT' => 31, 'NL' => 18,
+ 'NO' => 15, 'PL' => 28, 'PT' => 25, 'RO' => 24, 'SE' => 24, 'SI' => 19, 'SK' => 24,
+ 'SM' => 27, 'VA' => 22,
+ ];
+
+ /** Leerzeichen raus, Großbuchstaben -- die kanonische Form, die gespeichert wird. */
+ public static function normalize(string $iban): string
+ {
+ return strtoupper(preg_replace('/\s+/', '', $iban) ?? '');
+ }
+
+ public static function isValid(string $iban): bool
+ {
+ $iban = self::normalize($iban);
+
+ if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $iban) !== 1) {
+ return false;
+ }
+
+ $expected = self::LENGTHS[substr($iban, 0, 2)] ?? null;
+ if ($expected !== null && strlen($iban) !== $expected) {
+ return false;
+ }
+
+ return self::checksum($iban) === 1;
+ }
+
+ /** In Vierergruppen -- die Schreibweise auf Belegen und Formularen. */
+ public static function format(string $iban): string
+ {
+ return trim(chunk_split(self::normalize($iban), 4, ' '));
+ }
+
+ /**
+ * Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
+ * (A = 10 … Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
+ *
+ * Der Rest wird stellenweise fortgeschrieben, weil die Zahl sonst jeden Integer sprengt.
+ */
+ private static function checksum(string $iban): int
+ {
+ $rearranged = substr($iban, 4) . substr($iban, 0, 4);
+
+ $remainder = 0;
+ foreach (str_split($rearranged) as $char) {
+ $value = ctype_digit($char) ? $char : (string) (ord($char) - 55);
+
+ foreach (str_split($value) as $digit) {
+ $remainder = ($remainder * 10 + (int) $digit) % 97;
+ }
+ }
+
+ return $remainder;
+ }
+}
diff --git a/composer.json b/composer.json
index 989a38e..7be9f30 100644
--- a/composer.json
+++ b/composer.json
@@ -57,6 +57,10 @@
"@php artisan config:clear --ansi",
"@php artisan test"
],
+ "test:coverage": [
+ "@php artisan config:clear --ansi",
+ "@php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html storage/coverage --coverage-text"
+ ],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
diff --git a/database/migrations/2026_09_03_140010_create_refund_reasons.php b/database/migrations/2026_09_03_140010_create_refund_reasons.php
new file mode 100644
index 0000000..4587505
--- /dev/null
+++ b/database/migrations/2026_09_03_140010_create_refund_reasons.php
@@ -0,0 +1,63 @@
+string('slug')->primary();
+ $table->string('name');
+ $table->text('document_text')->nullable();
+ $table->boolean('requires_note')->default(false);
+ $table->integer('sort_order')->default(0);
+ $table->timestamps();
+ });
+
+ DB::table('refund_reasons')->insert([
+ [
+ 'slug' => 'sickness',
+ 'name' => 'Krankheitsbedingte Absage',
+ 'document_text' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.',
+ 'requires_note' => false,
+ 'sort_order' => 10,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'slug' => 'event_cancelled',
+ 'name' => 'Ausfall der Veranstaltung',
+ 'document_text' => 'Die Veranstaltung wurde abgesagt und konnte nicht stattfinden.',
+ 'requires_note' => false,
+ 'sort_order' => 20,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ // Der Text entsteht erst aus dem Freitext der Aktionsleitung -- deshalb hier leer.
+ 'slug' => 'other',
+ 'name' => 'Sonstiger Grund',
+ 'document_text' => null,
+ 'requires_note' => true,
+ 'sort_order' => 30,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('refund_reasons');
+ }
+};
diff --git a/database/migrations/2026_09_03_140020_create_participant_refunds.php b/database/migrations/2026_09_03_140020_create_participant_refunds.php
new file mode 100644
index 0000000..fbacd1c
--- /dev/null
+++ b/database/migrations/2026_09_03_140020_create_participant_refunds.php
@@ -0,0 +1,60 @@
+id();
+ $table->string('tenant');
+
+ $table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+
+ // Der öffentliche Link. Eigener Token statt des Teilnehmer-Identifiers, damit ein Abbruch den
+ // Link tötet, ohne andere per Identifier erreichbare Funktionen (GiroCode) mitzunehmen.
+ $table->string('token', 32)->unique();
+ $table->string('status');
+
+ $table->float('amount', 2)->default(0);
+ $table->string('reason')->nullable();
+ $table->text('reason_note')->nullable();
+
+ $table->string('account_owner')->nullable();
+ $table->string('account_iban')->nullable();
+
+ $table->foreignId('released_by')->nullable()->constrained('users', 'id')->nullOnDelete();
+ $table->dateTime('released_at')->nullable();
+ $table->dateTime('accepted_at')->nullable();
+ $table->dateTime('cancelled_at')->nullable();
+
+ $table->timestamps();
+
+ $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate();
+ $table->foreign('reason')->references('slug')->on('refund_reasons')->restrictOnDelete()->cascadeOnUpdate();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('participant_refunds');
+ }
+};
diff --git a/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php b/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php
new file mode 100644
index 0000000..f632ebd
--- /dev/null
+++ b/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php
@@ -0,0 +1,43 @@
+where('name', self::NAME)->first();
+
+ if ($existing !== null) {
+ return;
+ }
+
+ DB::table('page_texts')->insert([
+ 'name' => self::NAME,
+ 'content' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht '
+ . 'anderweitig zurückerstattet bekomme.',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ public function down(): void
+ {
+ DB::table('page_texts')->where('name', self::NAME)->delete();
+ }
+};
diff --git a/database/seed-scripts/participant-refund-template.sql b/database/seed-scripts/participant-refund-template.sql
new file mode 100644
index 0000000..f400776
--- /dev/null
+++ b/database/seed-scripts/participant-refund-template.sql
@@ -0,0 +1,134 @@
+-- Standard-Vorlage für den Erstattungsbeleg.
+--
+-- Bewusst ein manuelles Skript und keine Migration: der Vorlageninhalt wird über die Admin-Oberfläche
+-- gepflegt und soll von einem Release nicht überschrieben werden. Einspielen also nur bei der
+-- Erstinstallation oder wenn die Vorlage bewusst zurückgesetzt werden soll.
+--
+-- docker exec -i mareike-mareike-db mysql -u -p < database/seed-scripts/participant-refund-template.sql
+--
+-- Setzt participant-invoice-template.sql voraus: die Bilder ({asset:emblem}, {asset:logo},
+-- {asset:edge}) liegen in `document_assets` und werden von beiden Dokumentarten gemeinsam genutzt.
+--
+-- Der Beleg ist kein Schreiben des Verbands, sondern die Erklärung des Teilis ("Ich bitte um …", "Ich
+-- versichere …") -- ein Eigenbeleg.
+--
+-- Briefkopf samt Anschriftenfeld ist der der Rechnung: die Anschrift der erklärenden Person steht dort
+-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht,
+-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil).
+--
+-- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili
+-- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text.
+
+DELETE FROM document_templates WHERE document_type = 'participant_refund';
+
+INSERT INTO document_templates (document_type, block, content, sort_order, editable, created_at, updated_at) VALUES
+ ('participant_refund', 'layout', '
+
+
+ du hast dich von der Veranstaltung "{{$eventTitle}}" abgemeldet. Die Aktionsleitung hat dir daraufhin
+ eine Rückerstattung deines Teilnahmebeitrags freigegeben.
+
+
+
+
+
Betrag:
+
{{$amount}}
+
+
+
Grund:
+
{{$reason}}
+
+ @if (!empty($reasonNote))
+
+
Anmerkung:
+
{{$reasonNote}}
+
+ @endif
+
+
+
+ Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
+ folgenden Link ein:
+
+ Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:
+ {{$link}}
+
+
+
+ Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag
+ selbst steht fest und lässt sich über den Link nicht ändern – wenn du dazu Fragen hast, wende
+ dich bitte an die Aktionsleitung.
+