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, ]); } }