| Kontoinhaber*in: |
{{$accountOwner}} |
@@ -28,13 +35,21 @@
@if ($hasDocument)
- Im Anhang findest du den Beleg über die Rückerstattung als PDF.
+ Im Anhang findest du deinen Beleg über die Rückerstattung als PDF – nur zu deiner
+ Information, eingereicht ist er bereits.
+
+@endif
+
+@if ($myInvoicesUrl)
+
+ Den Stand deiner Abrechnung kannst du jederzeit selbst nachsehen: Melde dich in mareike an und
+ öffne Meine Abrechnungen.
@endif
- Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde
- dich bitte umgehend bei ihr.
+ Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
+ Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
diff --git a/tests/Feature/InvoiceTypeSelectionTest.php b/tests/Feature/InvoiceTypeSelectionTest.php
new file mode 100644
index 0000000..f5ddf4c
--- /dev/null
+++ b/tests/Feature/InvoiceTypeSelectionTest.php
@@ -0,0 +1,93 @@
+ 'wm',
+ 'name' => 'Wilde Möhre',
+ 'email' => 'wm@example.com',
+ 'email_finance' => 'wm-f@example.com',
+ 'url' => parse_url(config('app.url'), PHP_URL_HOST),
+ 'account_name' => 'Wilde Möhre e.V.',
+ 'account_iban' => 'DE00',
+ 'account_bic' => 'XY',
+ 'city' => 'Stadt',
+ 'postcode' => '00000',
+ 'is_active_local_group' => true,
+ 'has_active_instance' => true,
+ ]);
+
+ app()->instance('tenant', $tenant);
+
+ // Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt.
+ DB::table('invoice_types')->insert([
+ ['slug' => InvoiceType::INVOICE_TYPE_PROGRAM, 'name' => 'Programmkosten', 'sort_order' => 1, 'selectable' => true],
+ ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten', 'sort_order' => 3, 'selectable' => true],
+ ]);
+ }
+
+ public function test_the_refund_type_exists_but_is_not_selectable(): void
+ {
+ // `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`.
+ $type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
+
+ $this->assertNotNull($type, 'Der Typ muss existieren -- invoices.type ist ein Fremdschlüssel darauf.');
+ $this->assertFalse($type->selectable);
+ }
+
+ public function test_it_is_missing_from_the_new_invoice_form(): void
+ {
+ $response = $this->getJson('/api/v1/core/retrieve-invoice-types');
+
+ $response->assertOk();
+ $slugs = array_column($response->json('invoiceTypes'), 'slug');
+
+ $this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
+ $this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
+ $this->assertContains(InvoiceType::INVOICE_TYPE_OTHER, $slugs);
+ }
+
+ public function test_it_is_missing_when_a_treasurer_rebooks(): void
+ {
+ // Ohne diese Sperre ließe sich jede beliebige Abrechnung nachträglich zu einer
+ // Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
+ $response = $this->getJson('/api/v1/core/retrieve-invoice-types-all');
+
+ $response->assertOk();
+ $slugs = array_column($response->json('invoiceTypes'), 'slug');
+
+ $this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
+ $this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
+ // "Sonstige Kosten" steht dort bewusst am Ende.
+ $this->assertSame(InvoiceType::INVOICE_TYPE_OTHER, end($slugs));
+ }
+
+ public function test_selectable_returns_only_choosable_types_in_order(): void
+ {
+ $this->assertSame(
+ [InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
+ InvoiceType::selectable()->pluck('slug')->all()
+ );
+ }
+}
diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php
index 819dabe..ee70f38 100644
--- a/tests/Feature/ParticipantRefundTest.php
+++ b/tests/Feature/ParticipantRefundTest.php
@@ -10,10 +10,14 @@ use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocum
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\EfzStatus;
+use App\Enumerations\CostUnitType;
+use App\Enumerations\InvoiceStatus;
+use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
+use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
@@ -67,6 +71,10 @@ class ParticipantRefundTest extends TestCase
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
+ // Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen.
+ // `invoice_types.participation_refund` bringt die Migration bereits mit.
+ DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
+ DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
@@ -112,6 +120,20 @@ class ParticipantRefundTest extends TestCase
]);
}
+ /** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */
+ private function makeCostUnit(array $attributes = []): CostUnit
+ {
+ return CostUnit::create(array_merge([
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
+ 'distance_allowance' => 0.25,
+ 'mail_on_new' => false,
+ 'allow_new' => true,
+ 'archived' => false,
+ ], $attributes));
+ }
+
private function makeEvent(array $attributes = []): Event
{
$fee = EventParticipationFee::create([
@@ -125,6 +147,7 @@ class ParticipantRefundTest extends TestCase
]);
return Event::create(array_merge([
+ 'cost_unit_id' => $this->makeCostUnit()->id,
'tenant' => $this->tenant->slug,
'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(),
@@ -532,18 +555,32 @@ class ParticipantRefundTest extends TestCase
|--------------------------------------------------------------------------
*/
- public function test_amount_paid_is_untouched_through_the_whole_process(): void
+ public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void
{
$participant = $this->makeParticipant($this->makeEvent());
+ // Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag
+ // unverändert beim Verband.
$refund = $this->release($participant)->refund;
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->accept($refund);
- $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+
+ // Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
+ public function test_a_cancelled_release_leaves_amount_paid_alone(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent());
+ $refund = $this->release($participant)->refund;
+
+ new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
+
+ $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ }
+
/*
|--------------------------------------------------------------------------
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
@@ -565,6 +602,22 @@ class ParticipantRefundTest extends TestCase
]);
}
+ /** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */
+ private function makeParticipantUser(): User
+ {
+ return User::create([
+ 'username' => 'teili-' . uniqid() . '@example.com',
+ 'email' => 'teili-' . uniqid() . '@example.com',
+ 'firstname' => 'Mika',
+ 'lastname' => 'Muster',
+ 'password' => bcrypt('secret'),
+ 'local_group' => $this->tenant->slug,
+ 'user_role_main' => UserRole::USER_ROLE_USER,
+ 'user_role_local_group' => UserRole::USER_ROLE_USER,
+ 'active' => true,
+ ]);
+ }
+
public function test_release_over_http_creates_the_refund(): void
{
$this->actingAs($this->makeAdmin());
@@ -753,6 +806,40 @@ class ParticipantRefundTest extends TestCase
$this->assertStringContainsString('Anhang', $html);
}
+ public function test_the_acceptance_mail_points_at_the_invoice(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]);
+ $refund = $this->release($participant)->refund;
+ $this->accept($refund);
+
+ $html = new RefundAcceptedMail($participant, $refund->fresh())->render();
+
+ // Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits.
+ $this->assertStringContainsString('musst nichts weiter tun', $html);
+ $this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html);
+ $this->assertStringContainsString('/invoice/my-invoices/new', $html);
+ }
+
+ public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void
+ {
+ // Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung.
+ $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
+ $refund = $this->release($participant)->refund;
+ $this->accept($refund);
+
+ $html = new RefundAcceptedMail($participant, $refund->fresh())->render();
+
+ $this->assertStringNotContainsString('/invoice/my-invoices/new', $html);
+ }
+
+ public function test_an_empty_second_address_gets_no_mail(): void
+ {
+ // Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL.
+ $this->release($this->makeParticipant($this->makeEvent(), ['email_2' => '']));
+
+ Mail::assertSent(RefundReleasedMail::class, 1);
+ }
+
public function test_the_acceptance_mail_renders_without_a_document(): void
{
$participant = $this->makeParticipant($this->makeEvent());
diff --git a/tests/Feature/RefundInvoiceTest.php b/tests/Feature/RefundInvoiceTest.php
new file mode 100644
index 0000000..2f05fa5
--- /dev/null
+++ b/tests/Feature/RefundInvoiceTest.php
@@ -0,0 +1,427 @@
+tenant = Tenant::create([
+ 'slug' => 'wm',
+ 'name' => 'Wilde Möhre',
+ 'address_1' => 'Musterweg 1',
+ 'email' => 't@example.com',
+ 'email_finance' => 'finance@example.com',
+ 'url' => parse_url(config('app.url'), PHP_URL_HOST),
+ 'account_name' => 'Test e.V.',
+ 'account_iban' => 'DE00',
+ 'account_bic' => 'XY',
+ 'city' => 'Stadt',
+ 'postcode' => '00000',
+ 'invoice_prefix' => 'WM',
+ 'is_active_local_group' => true,
+ 'has_active_instance' => true,
+ ]);
+
+ app()->instance('tenant', $this->tenant);
+
+ DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
+ DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
+ DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
+ DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
+ PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
+ EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
+
+ foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
+ UserRole::create(['slug' => $role, 'name' => $role]);
+ }
+
+ $this->seedTemplate();
+
+ // Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage.
+ Storage::fake('local');
+ Mail::fake();
+ }
+
+ private function seedTemplate(): void
+ {
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_LAYOUT,
+ 'content' => '
{block:body}
',
+ 'sort_order' => 10,
+ ]);
+
+ DocumentTemplate::create([
+ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
+ 'block' => DocumentTemplate::BLOCK_BODY,
+ 'content' => '{recipient_name}
{details_table}',
+ 'sort_order' => 20,
+ ]);
+ }
+
+ private function makeCostUnit(array $attributes = []): CostUnit
+ {
+ return CostUnit::create(array_merge([
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
+ 'distance_allowance' => 0.25,
+ 'mail_on_new' => false,
+ 'allow_new' => true,
+ 'archived' => false,
+ ], $attributes));
+ }
+
+ private function makeEvent(array $attributes = []): Event
+ {
+ $fee = EventParticipationFee::create([
+ 'tenant' => $this->tenant->slug,
+ 'type' => 'participant',
+ 'name' => 'Sippe',
+ 'description' => null,
+ 'amount_standard' => 60.0,
+ 'amount_reduced' => null,
+ 'amount_solidarity' => null,
+ ]);
+
+ return Event::create(array_merge([
+ 'cost_unit_id' => $this->makeCostUnit()->id,
+ 'tenant' => $this->tenant->slug,
+ 'name' => 'Sommerlager',
+ 'identifier' => 'evt-' . uniqid(),
+ 'location' => 'Ort',
+ 'postal_code' => '00000',
+ 'email' => 'e@example.com',
+ 'start_date' => '2026-07-16',
+ 'end_date' => '2026-07-20',
+ 'early_bird_end' => '2026-06-20',
+ 'registration_final_end' => '2026-07-01',
+ 'early_bird_end_amount_increase' => 0,
+ 'account_owner' => 'Owner',
+ 'account_iban' => 'DE00',
+ 'participation_fee_type' => 'fixed',
+ 'participation_fee_1' => $fee->id,
+ 'pay_per_day' => true,
+ 'pay_direct' => false,
+ 'tax_liable' => false,
+ 'vat_rate' => 0,
+ 'vat_pricing_mode' => 'inclusive',
+ 'invoice_key' => 'WM-V-20260701',
+ ], $attributes));
+ }
+
+ private function makeUser(): User
+ {
+ return User::create([
+ 'username' => 'teili-' . uniqid() . '@example.com',
+ 'email' => 'teili-' . uniqid() . '@example.com',
+ 'firstname' => 'Mika',
+ 'lastname' => 'Muster',
+ 'password' => bcrypt('secret'),
+ 'local_group' => $this->tenant->slug,
+ 'user_role_main' => UserRole::USER_ROLE_USER,
+ 'user_role_local_group' => UserRole::USER_ROLE_USER,
+ 'active' => true,
+ ]);
+ }
+
+ private function makeParticipant(Event $event, array $attributes = []): EventParticipant
+ {
+ $this->sequence++;
+
+ return $event->participants()->create(array_merge([
+ 'tenant' => $this->tenant->slug,
+ 'identifier' => 'p-' . uniqid(),
+ 'invoice_sequence' => $this->sequence,
+ 'user_id' => $this->makeUser()->id,
+ 'firstname' => 'Mika',
+ 'lastname' => 'Muster',
+ 'participation_type' => 'participant',
+ 'fee_type' => 'standard',
+ 'sibling_reduction' => false,
+ 'local_group' => $this->tenant->slug,
+ 'birthday' => '2000-01-01',
+ 'address_1' => 'Beispielstraße 3',
+ 'postcode' => '11111',
+ 'city' => 'Beispielstadt',
+ 'email_1' => 'mika@example.com',
+ 'phone_1' => '0170 0000000',
+ 'arrival_date' => '2026-07-16',
+ 'departure_date' => '2026-07-20',
+ 'arrival_eating' => 1,
+ 'departure_eating' => 1,
+ 'amount' => 300.0,
+ 'amount_paid' => 300.0,
+ 'unregistered_at' => '2026-06-12',
+ 'payment_purpose' => 'Sommerlager',
+ 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
+ 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
+ ], $attributes));
+ }
+
+ /** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */
+ private function runRefund(
+ ?EventParticipant $participant = null,
+ float $amount = 220.0,
+ string $reason = RefundReason::SICKNESS,
+ ): ParticipantRefund {
+ $participant ??= $this->makeParticipant($this->makeEvent());
+
+ $refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
+ participant: $participant,
+ amount: new Amount($amount, 'Euro'),
+ reason: $reason,
+ ))->execute()->refund;
+
+ new AcceptRefundCommand(new AcceptRefundRequest(
+ refund: $refund,
+ accountOwner: 'Mika Muster',
+ accountIban: 'DE02120300000000202051',
+ declarationAccepted: true,
+ ))->execute();
+
+ return $refund->fresh();
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Die Abrechnung entsteht
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void
+ {
+ $event = $this->makeEvent();
+ $this->runRefund($this->makeParticipant($event));
+
+ $invoice = Invoice::first();
+
+ $this->assertNotNull($invoice);
+ $this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
+ $this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
+ $this->assertSame($event->cost_unit_id, $invoice->cost_unit_id);
+ $this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
+ $this->assertFalse((bool) $invoice->donation);
+ }
+
+ public function test_contact_details_come_from_the_participant(): void
+ {
+ $this->runRefund();
+
+ $invoice = Invoice::first();
+
+ $this->assertSame('Mika Muster', $invoice->contact_name);
+ $this->assertSame('mika@example.com', $invoice->contact_email);
+ $this->assertSame('0170 0000000', $invoice->contact_phone);
+ }
+
+ public function test_bank_details_come_from_the_refund_not_the_participant(): void
+ {
+ $this->runRefund();
+
+ $invoice = Invoice::first();
+
+ $this->assertSame('Mika Muster', $invoice->contact_bank_owner);
+ $this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban);
+ }
+
+ public function test_the_invoice_belongs_to_the_participants_user_account(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent());
+ $this->runRefund($participant);
+
+ $invoice = Invoice::first();
+
+ $this->assertSame($participant->user_id, $invoice->user_id);
+ // Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id,
+ // sobald ein Verwendungszweck gesetzt ist.
+ $this->assertNull($invoice->payment_purpose);
+ }
+
+ public function test_an_anonymous_participant_still_gets_an_invoice(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
+ $this->runRefund($participant);
+
+ $invoice = Invoice::first();
+
+ $this->assertNotNull($invoice);
+ $this->assertNull($invoice->user_id);
+ }
+
+ public function test_the_notice_names_event_reason_and_the_amount_paid(): void
+ {
+ $this->runRefund();
+
+ // Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird --
+ // sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen.
+ $this->assertSame(
+ 'Rückerstattung Teilnahmebeitrag Sommerlager – Krankheitsbedingte Absage'
+ . ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro',
+ Invoice::first()->comment
+ );
+ }
+
+ public function test_a_long_notice_keeps_the_amount_paid(): void
+ {
+ $event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]);
+ $this->runRefund($this->makeParticipant($event));
+
+ $comment = Invoice::first()->comment;
+
+ // Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen.
+ $this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment);
+ $this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.');
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Der Eigenbeleg hängt an der Abrechnung
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_the_receipt_is_attached_and_stored(): void
+ {
+ $event = $this->makeEvent();
+ $this->runRefund($this->makeParticipant($event));
+
+ $invoice = Invoice::first();
+
+ $this->assertNotNull($invoice->document_filename);
+ // Dasselbe Ablagemuster wie bei hochgeladenen Belegen.
+ $this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename);
+ $this->assertStringEndsWith('.pdf', $invoice->document_filename);
+
+ Storage::disk('local')->assertExists($invoice->document_filename);
+ $this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
+ }
+
+ public function test_the_refund_points_at_its_invoice(): void
+ {
+ $refund = $this->runRefund();
+
+ $this->assertSame(Invoice::first()->id, $refund->invoice_id);
+ $this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Die Kostenstelle
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_without_a_cost_unit_nothing_is_stored(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
+
+ $refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
+ participant: $participant,
+ amount: new Amount(220.0, 'Euro'),
+ reason: RefundReason::SICKNESS,
+ ))->execute()->refund;
+
+ $response = new AcceptRefundCommand(new AcceptRefundRequest(
+ refund: $refund,
+ accountOwner: 'Mika Muster',
+ accountIban: 'DE02120300000000202051',
+ declarationAccepted: true,
+ ))->execute();
+
+ $this->assertFalse($response->success);
+ $this->assertStringContainsString('Aktionsleitung', $response->message);
+
+ // Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann.
+ $refund->refresh();
+ $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
+ $this->assertNull($refund->account_iban);
+ $this->assertSame(0, Invoice::count());
+ }
+
+ public function test_a_closed_cost_unit_is_still_booked(): void
+ {
+ // Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist.
+ $costUnit = $this->makeCostUnit(['allow_new' => false]);
+ $event = $this->makeEvent(['cost_unit_id' => $costUnit->id]);
+
+ $this->runRefund($this->makeParticipant($event));
+
+ $this->assertSame($costUnit->id, Invoice::first()->cost_unit_id);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Der reguläre Weg läuft mit
+ |--------------------------------------------------------------------------
+ */
+
+ public function test_the_standard_confirmation_mail_is_sent(): void
+ {
+ $this->runRefund();
+
+ // Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg.
+ Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
+ }
+
+ public function test_amount_paid_is_cleared_once_the_invoice_exists(): void
+ {
+ $participant = $this->makeParticipant($this->makeEvent());
+ $this->runRefund($participant);
+
+ // Mit der eingereichten Abrechnung ist der Beitrag auf dem Weg zurück und darf in den
+ // Zahlungsübersichten nicht länger als eingegangen stehen.
+ $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
+ $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
+ }
+
+ public function test_the_receipt_is_written_before_the_amount_is_cleared(): void
+ {
+ $refund = $this->runRefund();
+
+ // Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh genullt,
+ // stünde dort 0,00 €. Der Beleg selbst liegt als PDF vor; nachprüfbar ist die Reihenfolge an der
+ // Anmerkung, die im selben Schritt und aus derselben Quelle entsteht.
+ $this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
+ $this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
+ }
+}