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. * * 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()); $refund = new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant, amount: new Amount($amount, 'Euro'), reason: $reason, retentionReason: $retentionReason, ))->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, retentionReason: RetentionReason::CANCELLATION_FEE, ))->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_settled_once_the_invoice_exists(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->runRefund($participant); // 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_settled(): void { $refund = $this->runRefund(); // 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(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001); } }