+ */
+ 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
index a50ca50..e5fe6d9 100644
--- a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
+++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
@@ -54,6 +54,11 @@ class RefundAcceptedMail extends Mailable
'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null,
'invoiceNumber' => $invoice?->invoice_number,
+ // Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist.
+ 'hasRetention' => $this->refund->hasRetention(),
+ 'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
+ 'retentionReason' => $this->refund->retentionReasonLabel(),
+ 'retentionReasonNote' => $this->refund->retention_reason_note,
// Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts
// eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken.
'capturedByManagement' => $this->refund->wasCapturedByManagement(),
diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php
index 5a0948d..afbe8e5 100644
--- a/app/Models/ParticipantRefund.php
+++ b/app/Models/ParticipantRefund.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Casts\AmountCast;
use App\Enumerations\RefundReason;
+use App\Enumerations\RetentionReason;
use App\Scopes\InstancedModel;
use App\ValueObjects\Amount;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -20,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property Amount|null $amount
* @property string|null $reason
* @property string|null $reason_note
+ * @property string|null $retention_reason
+ * @property string|null $retention_reason_note
+ * @property Amount|null $retained_amount
* @property string|null $account_owner
* @property string|null $account_iban
* @property int|null $captured_by
@@ -51,6 +55,9 @@ class ParticipantRefund extends InstancedModel
'amount',
'reason',
'reason_note',
+ 'retention_reason',
+ 'retention_reason_note',
+ 'retained_amount',
'account_owner',
'account_iban',
'captured_by',
@@ -63,6 +70,7 @@ class ParticipantRefund extends InstancedModel
protected $casts = [
'amount' => AmountCast::class,
+ 'retained_amount' => AmountCast::class,
'released_at' => 'datetime',
'accepted_at' => 'datetime',
'cancelled_at' => 'datetime',
@@ -129,4 +137,32 @@ class ParticipantRefund extends InstancedModel
{
return (string) ($this->reasonRelation()->first()?->name ?? '');
}
+
+ /** Der Einbehaltungsgrund als Stammdatensatz -- leer, wenn voll erstattet wurde. */
+ public function retentionReasonRelation(): BelongsTo
+ {
+ return $this->belongsTo(RetentionReason::class, 'retention_reason', 'slug');
+ }
+
+ public function retentionReasonLabel(): string
+ {
+ return (string) ($this->retentionReasonRelation()->first()?->name ?? '');
+ }
+
+ /** Der auf dem Beleg auszuweisende Text zur Einbehaltung. */
+ public function retentionReasonText(): string
+ {
+ return $this->retentionReasonRelation()->first()?->documentText($this->retention_reason_note) ?? '';
+ }
+
+ /**
+ * Ob etwas beim Verband bleibt -- die halbe Cent-Toleranz fängt die Float-Rundung ab.
+ *
+ * Der Betrag steht in `retained_amount` und wird beim Einreichen festgeschrieben. Ihn zur Laufzeit
+ * aus `amount_paid` zu rechnen ginge schief: Danach führt das Feld bereits den Rest.
+ */
+ public function hasRetention(): bool
+ {
+ return ($this->retained_amount?->getAmount() ?? 0.0) > 0.005;
+ }
}
diff --git a/app/Providers/GlobalDataProvider.php b/app/Providers/GlobalDataProvider.php
index e70831b..22b90e3 100644
--- a/app/Providers/GlobalDataProvider.php
+++ b/app/Providers/GlobalDataProvider.php
@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
+use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Models\AvailablePaymentMethod;
use App\Models\Tenant;
@@ -202,6 +203,11 @@ class GlobalDataProvider {
return response()->json(RefundReason::options());
}
+ /** Auswahl der Gründe, aus denen ein Teil des Beitrags beim Verband bleibt. */
+ public function getRetentionReasons() : JsonResponse {
+ return response()->json(RetentionReason::options());
+ }
+
public function getEventSettingData(Request $request) : JsonResponse {
return response()->json(
[
diff --git a/app/Resources/CostUnitResource.php b/app/Resources/CostUnitResource.php
index 15d156c..9dd570c 100644
--- a/app/Resources/CostUnitResource.php
+++ b/app/Resources/CostUnitResource.php
@@ -32,7 +32,10 @@ class CostUnitResource {
$amounts = [];
$overAllAmount = new Amount(0, 'Euro');
$overAllEstimatedAmount = new Amount(0, 'Euro');
- foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
+ // Nur echte Aufwandsarten: Eine Beitragserstattung ist die Rücknahme einer Einnahme und wird auf
+ // der Einnahmenseite bereits berücksichtigt -- hier gezählt, stünde sie ein zweites Mal in der
+ // Bilanz. `totalAmount` weiter oben bleibt davon unberührt, das ist die Kassensicht.
+ foreach (InvoiceType::countingAsExpense() as $invoiceType) {
$overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType));
$overAllEstimatedAmount->addAmount($costUnitRepository->sumupEstimatedByInvoiceType($this->costUnit, $invoiceType));
$amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString();
diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php
index 849188e..47d3e40 100644
--- a/app/Resources/EventResource.php
+++ b/app/Resources/EventResource.php
@@ -96,6 +96,14 @@ class EventResource extends JsonResource{
$returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']);
+ // Eigene Zeile in der Übersicht: In den Zeilen je Teilnahmeart hätte der Betrag nichts zu suchen,
+ // dort stehen nur aktive Anmeldungen.
+ $retainedFromUnregistered = $this->sumPaidOfUnregistered();
+ $returnArray['retainedFromUnregistered'] = [
+ 'value' => $retainedFromUnregistered->getAmount(),
+ 'readable' => $retainedFromUnregistered->toString(),
+ ];
+
$totalBalanceReal = new Amount(0, 'Euro');
$totalBalanceExpected = new Amount(0, 'Euro');
@@ -272,6 +280,13 @@ class EventResource extends JsonResource{
$realAmount->addAmount(new Amount($participantData['amount']['paid']['value'], 'Euro'));
}
+ // Was abgemeldete Teilis gezahlt haben und nicht zurückbekommen, gehört in beide Spalten: Das
+ // Geld liegt beim Verband (real) und fließt nicht mehr ab (erwartet). Ohne diese Zeile stünde
+ // jede Veranstaltung mit Abmeldungen dauerhaft schlechter da, als sie ist.
+ $retained = $this->sumPaidOfUnregistered();
+ $realAmount->addAmount($retained);
+ $expectedAmount->addAmount($retained);
+
return ['real' => [
'amount' => $realAmount,
'readable' => $realAmount->toString()
@@ -283,6 +298,28 @@ class EventResource extends JsonResource{
];
}
+ /**
+ * Was von abgemeldeten Teilis beim Verband geblieben ist.
+ *
+ * `amount_paid` führt nach einer Erstattung genau den einbehaltenen Rest; wurde nie erstattet, steht
+ * dort der volle gezahlte Beitrag. Beides ist Geld, das der Veranstaltung zusteht.
+ *
+ * Bewusst eine direkte Abfrage wie in {@see self::getParticipants()} nebenan -- ein einzelner
+ * Repository-Aufruf zwischen den Inline-Queries dieser Klasse würde sie uneinheitlicher machen.
+ */
+ public function sumPaidOfUnregistered() : Amount
+ {
+ $sum = new Amount(0, 'Euro');
+
+ foreach ($this->event->participants()->whereNotNull('unregistered_at')->get() as $participant) {
+ if ($participant->amount_paid !== null) {
+ $sum->addAmount($participant->amount_paid);
+ }
+ }
+
+ return $sum;
+ }
+
public function getParticipants(string $participationType) : array {
$returnData = [];
$returnData['amount'] = [
diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php
index 714ac1b..b732628 100644
--- a/app/Resources/ParticipantRefundResource.php
+++ b/app/Resources/ParticipantRefundResource.php
@@ -29,6 +29,12 @@ class ParticipantRefundResource extends JsonResource
'reason' => $this->resource->reason,
'reasonLabel' => $this->resource->reasonLabel(),
'reasonNote' => $this->resource->reason_note,
+ // Was beim Verband bleibt. `hasRetention` erspart dem Frontend den Betragsvergleich samt
+ // Rundungsfrage -- es soll nur entscheiden, ob der Hinweis angezeigt wird.
+ 'hasRetention' => $this->resource->hasRetention(),
+ 'retainedAmount' => $this->resource->retained_amount?->toString() ?? '0,00 Euro',
+ 'retentionReasonLabel' => $this->resource->retentionReasonLabel(),
+ 'retentionReasonNote' => $this->resource->retention_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/database/migrations/2026_09_07_140010_add_counts_as_expense_to_invoice_types.php b/database/migrations/2026_09_07_140010_add_counts_as_expense_to_invoice_types.php
new file mode 100644
index 0000000..086b539
--- /dev/null
+++ b/database/migrations/2026_09_07_140010_add_counts_as_expense_to_invoice_types.php
@@ -0,0 +1,38 @@
+boolean('counts_as_expense')->default(true)->after('selectable');
+ });
+
+ DB::table('invoice_types')
+ ->where('slug', 'participation_refund')
+ ->update(['counts_as_expense' => false]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('invoice_types', function (Blueprint $table) {
+ $table->dropColumn('counts_as_expense');
+ });
+ }
+};
diff --git a/database/migrations/2026_09_08_140010_create_retention_reasons.php b/database/migrations/2026_09_08_140010_create_retention_reasons.php
new file mode 100644
index 0000000..d867f6e
--- /dev/null
+++ b/database/migrations/2026_09_08_140010_create_retention_reasons.php
@@ -0,0 +1,72 @@
+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('retention_reasons')->insert([
+ [
+ 'slug' => 'cancellation_fee',
+ 'name' => 'Stornogebühr laut Ausschreibung',
+ 'document_text' => 'Einbehalten wurde die in der Ausschreibung genannte Stornogebühr.',
+ 'requires_note' => false,
+ 'sort_order' => 10,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'slug' => 'incurred_costs',
+ 'name' => 'Bereits entstandene Kosten',
+ 'document_text' => 'Einbehalten wurden Kosten, die zum Zeitpunkt der Abmeldung bereits entstanden waren.',
+ 'requires_note' => false,
+ 'sort_order' => 20,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'slug' => 'material',
+ 'name' => 'Bereits beschafftes Material',
+ 'document_text' => 'Einbehalten wurden Kosten für Material, das bereits beschafft wurde.',
+ 'requires_note' => false,
+ 'sort_order' => 30,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ // Der Text entsteht erst aus dem Freitext der Aktionsleitung -- deshalb hier leer.
+ 'slug' => 'custom',
+ 'name' => 'Sonstiger Grund',
+ 'document_text' => null,
+ 'requires_note' => true,
+ 'sort_order' => 40,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('retention_reasons');
+ }
+};
diff --git a/database/migrations/2026_09_08_140020_add_retention_reason_to_participant_refunds.php b/database/migrations/2026_09_08_140020_add_retention_reason_to_participant_refunds.php
new file mode 100644
index 0000000..8110785
--- /dev/null
+++ b/database/migrations/2026_09_08_140020_add_retention_reason_to_participant_refunds.php
@@ -0,0 +1,40 @@
+string('retention_reason')->nullable()->after('reason_note');
+ $table->text('retention_reason_note')->nullable()->after('retention_reason');
+ $table->float('retained_amount', 2)->default(0)->after('retention_reason_note');
+ });
+
+ // Der Fremdschlüssel in einem eigenen Aufruf: zusammen mit dem Anlegen der Spalte hat MariaDB ihn
+ // hier stillschweigend übergangen, und ein `down()` lief anschließend ins Leere.
+ Schema::table('participant_refunds', function (Blueprint $table) {
+ $table->foreign('retention_reason')->references('slug')->on('retention_reasons')
+ ->restrictOnDelete()->cascadeOnUpdate();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('participant_refunds', function (Blueprint $table) {
+ $table->dropForeign(['retention_reason']);
+ $table->dropColumn(['retention_reason', 'retention_reason_note', 'retained_amount']);
+ });
+ }
+};
diff --git a/resources/views/emails/events/refund_accepted.blade.php b/resources/views/emails/events/refund_accepted.blade.php
index a14f487..e051076 100644
--- a/resources/views/emails/events/refund_accepted.blade.php
+++ b/resources/views/emails/events/refund_accepted.blade.php
@@ -44,6 +44,13 @@
+@if ($hasRetention)
+
+ Von deinem gezahlten Beitrag verbleiben {{$retainedAmount}} beim Verband.
+ Grund: {{$retentionReason}}@if ($retentionReasonNote) – {{$retentionReasonNote}}@endif
+
+@endif
+
@if ($hasDocument)
Im Anhang findest du deinen Beleg über die Rückerstattung als PDF – nur zu deiner
diff --git a/routes/web.php b/routes/web.php
index 7650cfc..915b2e7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -57,6 +57,7 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/retrieve-invoice-types-all', [GlobalDataProvider::class, 'getAllInvoiceTypes']);
Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']);
Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']);
+ Route::get('/retrieve-retention-reasons', [GlobalDataProvider::class, 'getRetentionReasons']);
});
});
diff --git a/tests/Feature/EventBudgetTest.php b/tests/Feature/EventBudgetTest.php
new file mode 100644
index 0000000..c48adfa
--- /dev/null
+++ b/tests/Feature/EventBudgetTest.php
@@ -0,0 +1,324 @@
+tenant = Tenant::create([
+ 'slug' => 'wm',
+ 'name' => 'Wilde Möhre',
+ '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']);
+
+ // Ein gewöhnlicher Aufwandstyp zum Vergleich; die Beitragserstattung bringt die Migration mit.
+ DB::table('invoice_types')->insert([
+ 'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
+ 'name' => 'Programmkosten',
+ 'sort_order' => 1,
+ 'selectable' => true,
+ 'counts_as_expense' => true,
+ ]);
+
+ $this->costUnit = CostUnit::create([
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
+ 'distance_allowance' => 0.25,
+ 'mail_on_new' => false,
+ 'allow_new' => true,
+ 'archived' => false,
+ ]);
+ }
+
+ private function makeEvent(): 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([
+ 'cost_unit_id' => $this->costUnit->id,
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'identifier' => 'evt-' . uniqid(),
+ 'location' => 'Ort',
+ 'postal_code' => '00000',
+ 'email' => 'e@example.com',
+ 'start_date' => '2026-07-16',
+ 'end_date' => '2026-07-20',
+ 'early_bird_end' => '2026-06-20',
+ 'registration_final_end' => '2026-07-01',
+ 'early_bird_end_amount_increase' => 0,
+ 'account_owner' => 'Owner',
+ 'account_iban' => 'DE00',
+ 'participation_fee_type' => 'fixed',
+ 'participation_fee_1' => $fee->id,
+ 'pay_per_day' => true,
+ 'pay_direct' => false,
+ 'tax_liable' => false,
+ 'vat_rate' => 0,
+ 'vat_pricing_mode' => 'inclusive',
+ 'invoice_key' => 'WM-V-20260701',
+ ]);
+ }
+
+ private function makeInvoice(string $type, float $amount): Invoice
+ {
+ return Invoice::create([
+ 'tenant' => $this->tenant->slug,
+ 'cost_unit_id' => $this->costUnit->id,
+ 'invoice_number' => '2026-' . str_pad((string) Invoice::count() + 1, 4, '0', STR_PAD_LEFT),
+ 'status' => InvoiceStatus::INVOICE_STATUS_NEW,
+ 'type' => $type,
+ 'donation' => false,
+ 'contact_name' => 'Mika Muster',
+ 'amount' => $amount,
+ ]);
+ }
+
+ /** @return array */
+ private function costUnitData(): array
+ {
+ return new CostUnitResource($this->costUnit->fresh())->toArray(true);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Die Ausgabenliste
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_a_refund_does_not_appear_as_an_expense_row(): void
+ {
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
+
+ $amounts = $this->costUnitData()['amounts'];
+
+ $this->assertArrayNotHasKey(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $amounts);
+ $this->assertArrayHasKey(InvoiceType::INVOICE_TYPE_PROGRAM, $amounts);
+ }
+
+ public function test_a_refund_is_not_part_of_the_expense_total(): void
+ {
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
+
+ $this->assertEqualsWithDelta(0.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
+ }
+
+ public function test_an_ordinary_invoice_of_the_same_amount_does_count(): void
+ {
+ // Gegenprobe: Es liegt am Typ, nicht am Betrag.
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
+
+ $this->assertEqualsWithDelta(220.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
+ }
+
+ public function test_the_cash_view_still_shows_the_refund(): void
+ {
+ // `totalAmount` speist Kostenstellen-Liste und Dashboard -- dort fließt das Geld tatsächlich ab.
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
+
+ $this->assertStringContainsString('220,00', $this->costUnitData()['totalAmount']);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Die Bilanz der Veranstaltung
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_the_balance_is_not_reduced_by_a_refund(): void
+ {
+ // `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
+ // geladen sind und EventResource sie als Amount erwartet.
+ $event = $this->makeEvent()->fresh();
+ $before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
+
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
+
+ $after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
+
+ $this->assertEqualsWithDelta($before, $after, 0.001);
+ }
+
+ public function test_the_balance_is_reduced_by_an_ordinary_invoice(): void
+ {
+ // `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
+ // geladen sind und EventResource sie als Amount erwartet.
+ $event = $this->makeEvent()->fresh();
+ $before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
+
+ $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
+
+ $after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
+
+ $this->assertEqualsWithDelta($before - 220.0, $after, 0.001);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Beiträge abgemeldeter Teilis
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_money_left_by_unregistered_participants_counts_as_income(): void
+ {
+ $event = $this->makeEvent();
+ $before = new EventResource($event->fresh())->toArray(new Request());
+
+ // 80 € sind nach einer Teilerstattung beim Verband geblieben.
+ $this->makeUnregisteredParticipant($event, 80.0);
+
+ $after = new EventResource($event->fresh())->toArray(new Request());
+
+ $this->assertEqualsWithDelta(80.0, $after['retainedFromUnregistered']['value'], 0.001);
+
+ // In beide Spalten: Das Geld ist da (real) und fließt nicht mehr ab (erwartet).
+ $this->assertEqualsWithDelta(
+ $before['income']['real']['amount']->getAmount() + 80.0,
+ $after['income']['real']['amount']->getAmount(),
+ 0.001
+ );
+ $this->assertEqualsWithDelta(
+ $before['income']['expected']['amount']->getAmount() + 80.0,
+ $after['income']['expected']['amount']->getAmount(),
+ 0.001
+ );
+ $this->assertEqualsWithDelta(
+ $before['totalBalance']['real']['value'] + 80.0,
+ $after['totalBalance']['real']['value'],
+ 0.001
+ );
+ }
+
+ public function test_unregistered_participants_stay_out_of_lists_and_counts(): void
+ {
+ $event = $this->makeEvent();
+ $this->makeUnregisteredParticipant($event, 80.0);
+
+ $participantData = new EventResource($event->fresh())
+ ->toArray(new Request())['participants']['participant'];
+
+ // Nur die Geldsumme kommt hinzu -- in Listen und Zahlen bleiben Abgemeldete außen vor.
+ $this->assertSame(0, $participantData['count']);
+ $this->assertArrayNotHasKey('participants', $participantData);
+ $this->assertEqualsWithDelta(0.0, $participantData['amount']['paid']['value'], 0.001);
+ }
+
+ public function test_the_funding_does_not_grow_with_unregistered_participants(): void
+ {
+ $event = $this->makeEvent();
+ $before = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
+
+ $this->makeUnregisteredParticipant($event, 80.0);
+
+ $after = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
+
+ // Die wichtigste Gegenprobe: Wer nicht da war, bringt keine Förderung.
+ $this->assertEqualsWithDelta($before, $after, 0.001);
+ }
+
+ public function test_the_row_is_hidden_when_nothing_was_retained(): void
+ {
+ $event = $this->makeEvent();
+ $this->makeUnregisteredParticipant($event, 0.0);
+
+ // Die Zeile in der Übersicht hängt an diesem Wert -- bei 0 soll sie nicht erscheinen.
+ $this->assertEqualsWithDelta(
+ 0.0,
+ new EventResource($event->fresh())->toArray(new Request())['retainedFromUnregistered']['value'],
+ 0.001
+ );
+ }
+
+ /** Ein abgemeldeter Teili, bei dem der übergebene Betrag beim Verband geblieben ist. */
+ private function makeUnregisteredParticipant(Event $event, float $remaining): void
+ {
+ $event->participants()->create([
+ 'tenant' => $this->tenant->slug,
+ 'identifier' => 'p-' . uniqid(),
+ 'invoice_sequence' => $event->participants()->count() + 1,
+ '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' => $remaining,
+ 'unregistered_at' => '2026-06-12',
+ 'payment_purpose' => 'Sommerlager',
+ 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
+ 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
+ ]);
+ }
+}
diff --git a/tests/Feature/InvoiceTypeSelectionTest.php b/tests/Feature/InvoiceTypeSelectionTest.php
index f5ddf4c..9cc4e23 100644
--- a/tests/Feature/InvoiceTypeSelectionTest.php
+++ b/tests/Feature/InvoiceTypeSelectionTest.php
@@ -90,4 +90,16 @@ class InvoiceTypeSelectionTest extends TestCase
InvoiceType::selectable()->pluck('slug')->all()
);
}
+
+ public function test_the_refund_type_does_not_count_as_an_expense(): void
+ {
+ // Sie mindert die Einnahmenseite bereits -- als Ausgabe gezählt, stünde sie zweimal in der Bilanz.
+ $this->assertSame(
+ [InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
+ InvoiceType::countingAsExpense()->pluck('slug')->all()
+ );
+
+ $type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
+ $this->assertFalse($type->counts_as_expense);
+ }
}
diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php
index ee70f38..ccf4de3 100644
--- a/tests/Feature/ParticipantRefundTest.php
+++ b/tests/Feature/ParticipantRefundTest.php
@@ -14,6 +14,7 @@ use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
+use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
@@ -206,17 +207,23 @@ class ParticipantRefundTest extends TestCase
], $attributes));
}
+ /**
+ * Der Vorgabewert 300,00 € entspricht dem gezahlten Beitrag -- es bleibt also nichts einbehalten und
+ * es braucht keinen Einbehaltungsgrund. Bei kleineren Beträgen muss einer mitgegeben werden.
+ */
private function release(
EventParticipant $participant,
float $amount = 300.0,
string $reason = RefundReason::SICKNESS,
?string $note = null,
+ ?string $retentionReason = null,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
reasonNote: $note,
+ retentionReason: $retentionReason,
))->execute();
}
@@ -296,10 +303,12 @@ class ParticipantRefundTest extends TestCase
{
$participant = $this->makeParticipant($this->makeEvent());
- $response = $this->release($participant, 220.0);
+ $response = $this->release($participant, 220.0, retentionReason: RetentionReason::CANCELLATION_FEE);
$this->assertTrue($response->success);
$this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001);
+ // Der Rest wird am Vorgang festgeschrieben, nicht später gerechnet.
+ $this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
}
public function test_release_is_rejected_without_an_amount(): void
@@ -626,6 +635,8 @@ class ParticipantRefundTest extends TestCase
$response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,50',
'reason' => RefundReason::SICKNESS,
+ // Weniger als gezahlt -- der Rest bleibt beim Verband und braucht eine Begründung.
+ 'retentionReason' => RetentionReason::CANCELLATION_FEE,
]);
$response->assertOk()->assertJsonPath('status', 'success');
@@ -768,7 +779,13 @@ class ParticipantRefundTest extends TestCase
public function test_the_release_mail_renders_with_the_link(): void
{
$participant = $this->makeParticipant($this->makeEvent());
- $refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund;
+ $refund = $this->release(
+ $participant,
+ 220.0,
+ RefundReason::OTHER,
+ 'Umzug',
+ RetentionReason::CANCELLATION_FEE
+ )->refund;
$html = new RefundReleasedMail($participant, $refund)->render();
diff --git a/tests/Feature/RefundDirectCaptureTest.php b/tests/Feature/RefundDirectCaptureTest.php
index 63b153a..b086f48 100644
--- a/tests/Feature/RefundDirectCaptureTest.php
+++ b/tests/Feature/RefundDirectCaptureTest.php
@@ -10,6 +10,7 @@ use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
+use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
@@ -204,12 +205,18 @@ class RefundDirectCaptureTest extends TestCase
], $attributes));
}
- /** Freigabe mit bereits bekannter Bankverbindung. */
+ /**
+ * Freigabe mit bereits bekannter Bankverbindung.
+ *
+ * 220 von 300 gezahlten Euro: Es bleibt etwas beim Verband, deshalb gehört ein Einbehaltungsgrund
+ * dazu.
+ */
private function releaseWithBankDetails(
?EventParticipant $participant = null,
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
float $amount = 220.0,
+ ?string $retentionReason = RetentionReason::CANCELLATION_FEE,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant ?? $this->makeParticipant(),
@@ -217,6 +224,7 @@ class RefundDirectCaptureTest extends TestCase
reason: RefundReason::SICKNESS,
accountOwner: $owner,
accountIban: $iban,
+ retentionReason: $retentionReason,
))->execute();
}
@@ -252,7 +260,7 @@ class RefundDirectCaptureTest extends TestCase
$this->assertTrue($refund->wasCapturedByManagement());
}
- public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void
+ public function test_the_invoice_exists_and_the_paid_amount_is_settled(): void
{
$participant = $this->makeParticipant();
@@ -263,7 +271,19 @@ class RefundDirectCaptureTest extends TestCase
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
+ // 300 gezahlt, 220 erstattet -- die restlichen 80 bleiben beim Verband und werden dort geführt.
+ $this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ }
+
+ public function test_a_full_refund_leaves_nothing_behind(): void
+ {
+ $participant = $this->makeParticipant();
+
+ $this->releaseWithBankDetails($participant, amount: 300.0, retentionReason: null);
+
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ $this->assertEqualsWithDelta(0.0, ParticipantRefund::first()->retained_amount->getAmount(), 0.001);
+ $this->assertNull(ParticipantRefund::first()->retention_reason);
}
public function test_only_the_receipt_mail_goes_out(): void
@@ -299,6 +319,7 @@ class RefundDirectCaptureTest extends TestCase
participant: $this->makeParticipant(),
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
+ retentionReason: RetentionReason::CANCELLATION_FEE,
))->execute()->refund;
$refund->update([
@@ -379,6 +400,8 @@ class RefundDirectCaptureTest extends TestCase
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
+ // 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
+ 'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
])
@@ -396,6 +419,8 @@ class RefundDirectCaptureTest extends TestCase
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
+ // 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
+ 'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => '',
'accountIban' => '',
])
diff --git a/tests/Feature/RefundInvoiceTest.php b/tests/Feature/RefundInvoiceTest.php
index 2f05fa5..936297b 100644
--- a/tests/Feature/RefundInvoiceTest.php
+++ b/tests/Feature/RefundInvoiceTest.php
@@ -11,6 +11,7 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
+use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Models\CostUnit;
@@ -200,11 +201,16 @@ class RefundInvoiceTest extends TestCase
], $attributes));
}
- /** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */
+ /**
+ * Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili.
+ *
+ * 220 von 300 gezahlten Euro -- es bleibt etwas beim Verband, deshalb der Einbehaltungsgrund.
+ */
private function runRefund(
?EventParticipant $participant = null,
float $amount = 220.0,
string $reason = RefundReason::SICKNESS,
+ ?string $retentionReason = RetentionReason::CANCELLATION_FEE,
): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent());
@@ -212,6 +218,7 @@ class RefundInvoiceTest extends TestCase
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
+ retentionReason: $retentionReason,
))->execute()->refund;
new AcceptRefundCommand(new AcceptRefundRequest(
@@ -359,6 +366,7 @@ class RefundInvoiceTest extends TestCase
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
+ retentionReason: RetentionReason::CANCELLATION_FEE,
))->execute()->refund;
$response = new AcceptRefundCommand(new AcceptRefundRequest(
@@ -403,25 +411,26 @@ class RefundInvoiceTest extends TestCase
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
}
- public function test_amount_paid_is_cleared_once_the_invoice_exists(): void
+ public function test_amount_paid_is_settled_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);
+ // 300 gezahlt, 220 erstattet: `amount_paid` führt danach den Rest, der beim Verband bleibt.
+ $this->assertEqualsWithDelta(80.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_the_receipt_is_written_before_the_amount_is_cleared(): void
+ public function test_the_receipt_is_written_before_the_amount_is_settled(): 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.
+ // Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh
+ // verrechnet, stünden dort 80,00 € statt 300,00 €. Der Beleg selbst liegt als PDF vor;
+ // nachprüfbar ist die Reihenfolge an der Anmerkung, die im selben Schritt entsteht.
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
- $this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
+ $this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
}
}
diff --git a/tests/Feature/RefundRetentionTest.php b/tests/Feature/RefundRetentionTest.php
new file mode 100644
index 0000000..04cf6ad
--- /dev/null
+++ b/tests/Feature/RefundRetentionTest.php
@@ -0,0 +1,407 @@
+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();
+
+ Storage::fake('local');
+ Mail::fake();
+ }
+
+ /** Die Vorlage setzt den Datenblock ein -- daran lassen sich die Belegzeilen prüfen. */
+ private function seedTemplate(): void
+ {
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_LAYOUT,
+ 'content' => '{block:body}
',
+ 'sort_order' => 10,
+ ]);
+
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_BODY,
+ 'content' => '{details_table}{retained_amount} / {retention_note}
',
+ 'sort_order' => 20,
+ ]);
+ }
+
+ private function makeEvent(): Event
+ {
+ $fee = EventParticipationFee::create([
+ 'tenant' => $this->tenant->slug,
+ 'type' => 'participant',
+ 'name' => 'Sippe',
+ 'description' => null,
+ 'amount_standard' => 60.0,
+ 'amount_reduced' => null,
+ 'amount_solidarity' => null,
+ ]);
+
+ $costUnit = CostUnit::create([
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
+ 'distance_allowance' => 0.25,
+ 'mail_on_new' => false,
+ 'allow_new' => true,
+ 'archived' => false,
+ ]);
+
+ return Event::create([
+ 'cost_unit_id' => $costUnit->id,
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'identifier' => 'evt-' . uniqid(),
+ 'location' => 'Ort',
+ 'postal_code' => '00000',
+ 'email' => 'e@example.com',
+ 'start_date' => '2026-07-16',
+ 'end_date' => '2026-07-20',
+ 'early_bird_end' => '2026-06-20',
+ 'registration_final_end' => '2026-07-01',
+ 'early_bird_end_amount_increase' => 0,
+ 'account_owner' => 'Owner',
+ 'account_iban' => 'DE00',
+ 'participation_fee_type' => 'fixed',
+ 'participation_fee_1' => $fee->id,
+ 'pay_per_day' => true,
+ 'pay_direct' => false,
+ 'tax_liable' => false,
+ 'vat_rate' => 0,
+ 'vat_pricing_mode' => 'inclusive',
+ // Je Test können mehrere Veranstaltungen entstehen; der Schlüssel ist eindeutig.
+ 'invoice_key' => 'WM-V-2026070' . ($this->sequence + 1),
+ ]);
+ }
+
+ private function makeParticipant(array $attributes = []): EventParticipant
+ {
+ $this->sequence++;
+
+ $user = 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,
+ ]);
+
+ return $this->makeEvent()->participants()->create(array_merge([
+ 'tenant' => $this->tenant->slug,
+ 'identifier' => 'p-' . uniqid(),
+ 'invoice_sequence' => $this->sequence,
+ 'user_id' => $user->id,
+ 'firstname' => 'Mika',
+ 'lastname' => 'Muster',
+ 'participation_type' => 'participant',
+ 'fee_type' => 'standard',
+ 'sibling_reduction' => false,
+ 'local_group' => $this->tenant->slug,
+ 'birthday' => '2000-01-01',
+ 'address_1' => 'Beispielstraße 3',
+ 'postcode' => '11111',
+ 'city' => 'Beispielstadt',
+ 'email_1' => 'mika@example.com',
+ 'phone_1' => '0170 0000000',
+ 'arrival_date' => '2026-07-16',
+ 'departure_date' => '2026-07-20',
+ 'arrival_eating' => 1,
+ 'departure_eating' => 1,
+ 'amount' => 300.0,
+ 'amount_paid' => 300.0,
+ 'unregistered_at' => '2026-06-12',
+ 'payment_purpose' => 'Sommerlager',
+ 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
+ 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
+ ], $attributes));
+ }
+
+ private function release(
+ EventParticipant $participant,
+ float $amount,
+ ?string $retentionReason = null,
+ ?string $retentionNote = null,
+ ) {
+ return new ReleaseRefundCommand(new ReleaseRefundRequest(
+ participant: $participant,
+ amount: new Amount($amount, 'Euro'),
+ reason: RefundReason::SICKNESS,
+ retentionReason: $retentionReason,
+ retentionReasonNote: $retentionNote,
+ ))->execute();
+ }
+
+ private function accept(ParticipantRefund $refund): void
+ {
+ new AcceptRefundCommand(new AcceptRefundRequest(
+ refund: $refund,
+ accountOwner: 'Mika Muster',
+ accountIban: 'DE02120300000000202051',
+ declarationAccepted: true,
+ ))->execute();
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Der Grund ist Pflicht, sobald etwas bleibt
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_a_partial_refund_without_a_reason_is_refused(): void
+ {
+ $response = $this->release($this->makeParticipant(), 220.0);
+
+ $this->assertFalse($response->success);
+ $this->assertStringContainsString('einbehalten', $response->message);
+ $this->assertSame(0, ParticipantRefund::count());
+ }
+
+ public function test_a_full_refund_needs_no_reason(): void
+ {
+ $response = $this->release($this->makeParticipant(), 300.0);
+
+ $this->assertTrue($response->success);
+ $this->assertNull($response->refund->retention_reason);
+ $this->assertEqualsWithDelta(0.0, $response->refund->retained_amount->getAmount(), 0.001);
+ }
+
+ public function test_a_free_text_retention_reason_needs_its_note(): void
+ {
+ $response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, ' ');
+
+ $this->assertFalse($response->success);
+ $this->assertStringContainsString('Erläuterung', $response->message);
+ $this->assertSame(0, ParticipantRefund::count());
+ }
+
+ public function test_an_unknown_retention_reason_is_refused(): void
+ {
+ $response = $this->release($this->makeParticipant(), 220.0, 'erfunden');
+
+ $this->assertFalse($response->success);
+ $this->assertSame(0, ParticipantRefund::count());
+ }
+
+ public function test_a_reason_sent_with_a_full_refund_is_not_stored(): void
+ {
+ // Im Formular ist das Feld dann gar nicht sichtbar -- ein Wert ohne Bezug gehört nicht in die DB.
+ $response = $this->release($this->makeParticipant(), 300.0, RetentionReason::CANCELLATION_FEE);
+
+ $this->assertTrue($response->success);
+ $this->assertNull($response->refund->retention_reason);
+ }
+
+ public function test_the_note_is_only_stored_for_reasons_that_require_it(): void
+ {
+ $withNote = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt');
+ $this->assertSame('Bereits gebuchte Bahnfahrt', $withNote->refund->retention_reason_note);
+
+ $ignored = $this->release($this->makeParticipant(), 220.0, RetentionReason::MATERIAL, 'wird verworfen');
+ $this->assertNull($ignored->refund->retention_reason_note);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Was beim Verband bleibt
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_the_retained_amount_is_recorded_at_release(): void
+ {
+ $response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CANCELLATION_FEE);
+
+ // Festgeschrieben, nicht gerechnet: Nach dem Einreichen führt `amount_paid` bereits den Rest.
+ $this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
+ $this->assertTrue($response->refund->hasRetention());
+ }
+
+ public function test_amount_paid_keeps_the_retained_share(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
+
+ $this->accept($refund);
+
+ $this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ }
+
+ public function test_a_full_refund_leaves_nothing(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 300.0)->refund;
+
+ $this->accept($refund);
+
+ $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ }
+
+ public function test_a_second_refund_is_capped_at_the_remainder(): void
+ {
+ $participant = $this->makeParticipant();
+ $this->accept($this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund);
+
+ // Es liegen noch 80 € beim Verband -- mehr kann nicht zurückgehen.
+ $tooMuch = $this->release($participant->fresh(), 100.0, RetentionReason::CANCELLATION_FEE);
+ $this->assertFalse($tooMuch->success);
+
+ $fits = $this->release($participant->fresh(), 80.0);
+ $this->assertTrue($fits->success);
+ $this->assertEqualsWithDelta(0.0, $fits->refund->retained_amount->getAmount(), 0.001);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Sichtbarkeit
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_the_receipt_names_the_retained_amount_and_reason(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
+ $this->accept($refund);
+
+ $html = $this->renderReceipt($refund->fresh());
+
+ $this->assertStringContainsString('Einbehalten', $html);
+ $this->assertStringContainsString('80,00', $html);
+ $this->assertStringContainsString('Stornogebühr laut Ausschreibung', $html);
+ }
+
+ public function test_the_receipt_stays_silent_on_a_full_refund(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 300.0)->refund;
+ $this->accept($refund);
+
+ $this->assertStringNotContainsString('Einbehalten', $this->renderReceipt($refund->fresh()));
+ }
+
+ public function test_the_mail_explains_the_retention(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt')->refund;
+ $this->accept($refund);
+
+ $html = new RefundAcceptedMail($participant, $refund->fresh())->render();
+
+ $this->assertStringContainsString('80,00 Euro', $html);
+ $this->assertStringContainsString('Sonstiger Grund', $html);
+ $this->assertStringContainsString('Bereits gebuchte Bahnfahrt', $html);
+ }
+
+ public function test_the_mail_stays_silent_on_a_full_refund(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 300.0)->refund;
+ $this->accept($refund);
+
+ $this->assertStringNotContainsString('verbleiben beim Verband', new RefundAcceptedMail($participant, $refund->fresh())->render());
+ }
+
+ public function test_the_resource_carries_the_retention_for_the_lists(): void
+ {
+ $participant = $this->makeParticipant();
+ $refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
+ $this->accept($refund);
+
+ $data = $refund->fresh()->toResource()->toArray(request());
+
+ $this->assertTrue($data['hasRetention']);
+ $this->assertSame('80,00 Euro', $data['retainedAmount']);
+ $this->assertSame('Stornogebühr laut Ausschreibung', $data['retentionReasonLabel']);
+ }
+
+ private function renderReceipt(ParticipantRefund $refund): string
+ {
+ $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
+ $number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
+ $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
+
+ return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
+ }
+}