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']); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); // Aktive Anmeldungen laufen für die Fördertage durch `EventParticipantResource`, und die liest // Erlaubnisse und Essgewohnheit ohne Null-Prüfung. EatingHabit::create([ 'slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Alles', ]); SwimmingPermission::create([ 'slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Erlaubt', 'short' => 'ja', ]); FirstAidPermission::create([ 'slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Erlaubt', 'description' => 'Erlaubt', ]); foreach ([ InvoiceStatus::INVOICE_STATUS_NEW, InvoiceStatus::INVOICE_STATUS_APPROVED, InvoiceStatus::INVOICE_STATUS_EXPORTED, InvoiceStatus::INVOICE_STATUS_DENIED, InvoiceStatus::INVOICE_STATUS_DELETED, ] as $status) { DB::table('invoice_status')->insert(['slug' => $status]); } foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { UserRole::create(['slug' => $role, 'name' => $role]); } // Zwei gewöhnliche Aufwandstypen plus einer, der leer bleibt; die Beitragserstattung bringt die // Migration mit. foreach ([ [InvoiceType::INVOICE_TYPE_PROGRAM, 'Programmkosten', 1], [InvoiceType::INVOICE_TYPE_CATERING, 'Verpflegungskosten', 1], [InvoiceType::INVOICE_TYPE_OTHER, 'Sonstige Kosten', 3], ] as [$slug, $name, $sortOrder]) { DB::table('invoice_types')->insert([ 'slug' => $slug, 'name' => $name, 'sort_order' => $sortOrder, '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, ]); } /* |-------------------------------------------------------------------------- | Einnahmen |-------------------------------------------------------------------------- */ public function test_only_paid_contributions_count(): void { $event = $this->makeEvent(); $this->makeParticipant($event, amount: 300.0, amountPaid: 300.0); $this->makeParticipant($event, amount: 300.0, amountPaid: 0.0); // 600 € wurden erwartet, 300 € sind angekommen -- die EÜR kennt nur die 300. $this->assertEqualsWithDelta(300.0, $this->entry('Teilnahmebeiträge')->getAmount(), 0.001); } public function test_money_retained_from_unregistered_participants_is_its_own_line(): void { $event = $this->makeEvent(); $this->makeParticipant($event, amount: 300.0, amountPaid: 300.0); $this->makeParticipant($event, amount: 300.0, amountPaid: 80.0, unregistered: true); // Der Rest einer Teilerstattung liegt beim Verband, gehört aber nicht zu den Beiträgen: Dort // stehen nur aktive Anmeldungen. $this->assertEqualsWithDelta(300.0, $this->entry('Teilnahmebeiträge')->getAmount(), 0.001); $this->assertEqualsWithDelta(80.0, $this->entry('Einbehaltene Einnahmen aus Abmeldungen')->getAmount(), 0.001); } public function test_the_two_income_categories_add_up(): void { $event = $this->makeEvent(); $this->makeParticipant($event, amount: 300.0, amountPaid: 300.0); $this->makeParticipant($event, amount: 300.0, amountPaid: 80.0, unregistered: true); $income = $this->statement()->income; // Eigenmittel: 300 Beiträge + 1000 weitere Einnahmen + 80 einbehalten. $this->assertSame('Eigenmittel', $income['categories'][0]['name']); $this->assertEqualsWithDelta(1000.0, $this->entry('Weitere Einnahmen')->getAmount(), 0.001); $this->assertEqualsWithDelta(1380.0, $income['categories'][0]['total']->getAmount(), 0.001); // Förderungen: 2,00 € p.P./Tag über 4 Fördertage der einen aktiven Anmeldung. $this->assertSame('Förderungen', $income['categories'][1]['name']); $this->assertEqualsWithDelta(8.0, $income['categories'][1]['total']->getAmount(), 0.001); $this->assertEqualsWithDelta(1388.0, $income['total']->getAmount(), 0.001); } public function test_the_funding_line_names_the_rate(): void { $this->makeEvent(); $funding = $this->statement()->income['categories'][1]['entries'][0]; $this->assertSame('Fördermittel (2,00 € p.P./Tag)', $funding['name']); } /* |-------------------------------------------------------------------------- | Ausgaben |-------------------------------------------------------------------------- */ public function test_only_receipts_representing_real_money_count(): void { $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 50.0, InvoiceStatus::INVOICE_STATUS_APPROVED); $this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 30.0, InvoiceStatus::INVOICE_STATUS_EXPORTED); // Kein Geldfluss, also keine Ausgabe: $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); $this->assertEqualsWithDelta(150.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001); $this->assertEqualsWithDelta(30.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001); $this->assertEqualsWithDelta(180.0, $this->statement()->expenses['total']->getAmount(), 0.001); } public function test_a_refund_is_no_expense(): void { $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED); $expenses = $this->statement()->expenses; $this->assertEqualsWithDelta(0.0, $expenses['total']->getAmount(), 0.001); $this->assertSame( [], array_values(array_filter($expenses['groups'], fn ($group) => $group['name'] === 'Beitragserstattung')) ); } public function test_every_expense_type_gets_a_row_even_without_receipts(): void { $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $groups = $this->statement()->expenses['groups']; // Die Gliederung bleibt vollständig -- eine fehlende Zeile ließe sich sonst mit einer vergessenen // verwechseln. $this->assertSame( ['Programmkosten', 'Verpflegungskosten', 'Sonstige Kosten'], array_column($groups, 'name') ); $this->assertEqualsWithDelta(0.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001); } public function test_a_type_without_receipts_has_no_attachment_rows(): void { $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $this->assertCount(1, $this->group('Programmkosten')['rows']); $this->assertCount(0, $this->group('Verpflegungskosten')['rows']); } public function test_the_attachment_names_the_purpose_for_every_type(): void { // "Was wurde eingekauft" wird zu jeder Abrechnung erfasst, nicht nur zu "Sonstige Kosten" -- // dadurch trägt die Zweck-Spalte auch bei Programmkosten eine Aussage. $this->makeEvent(); $this->makeInvoice( InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, typeOther: 'Bastelmaterial' ); $this->assertSame('Bastelmaterial', $this->group('Programmkosten')['rows'][0]['purpose']); } public function test_a_recorded_purpose_wins(): void { // Der Regelfall für neue Belege: Der Zahlungsgrund wird beim Einreichen erfasst und steht in // seiner eigenen Spalte -- korrigierbar, ohne dass ihn etwas wieder überschreibt. $this->makeEvent(); $this->makeInvoice( InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, typeOther: 'Bastelmaterial', purpose: 'Material für den Bastelnachmittag' ); $this->assertSame( 'Material für den Bastelnachmittag', $this->group('Programmkosten')['rows'][0]['purpose'] ); } public function test_travel_costs_name_the_reason_and_who_travelled(): void { // Bei Fahrtkosten bleibt `type_other` leer -- die Strecke landet in `travel_direction`. Der Zweck // wird deshalb wie in der Beleg-Übersicht ermittelt, über Invoice::purposeText(). $this->makeEvent(); // Der Typ nur hier, nicht im setUp(): dort stehen bewusst drei Typen, deren Gliederung ein // anderer Test wörtlich prüft. DB::table('invoice_types')->insert([ 'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING, 'name' => 'Fahrtkosten', 'sort_order' => 1, 'selectable' => true, 'counts_as_expense' => true, ]); $this->makeInvoice( InvoiceType::INVOICE_TYPE_TRAVELLING, 88.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, travelReason: 'Landeslager' ); $this->assertSame('Landeslager — Mika Muster', $this->group('Fahrtkosten')['rows'][0]['purpose']); } public function test_the_comment_is_no_longer_part_of_the_purpose(): void { // Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem // Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben // hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren. $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']); } /* |-------------------------------------------------------------------------- | Ergebnis und Auslieferung |-------------------------------------------------------------------------- */ public function test_the_result_is_income_minus_expenses(): void { $event = $this->makeEvent(); $this->makeParticipant($event, amount: 300.0, amountPaid: 300.0); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $statement = $this->statement(); $this->assertEqualsWithDelta( $statement->income['total']->getAmount() - $statement->expenses['total']->getAmount(), $statement->result->getAmount(), 0.001 ); } public function test_amounts_are_printed_in_german_notation(): void { // `Amount::getFormattedAmount()` ersetzt jeden Punkt durch ein Komma und macht aus 1.487,50 ein // "1,487,50". Auf einer Aufstellung mit vierstelligen Beträgen wäre das nicht lesbar. $this->assertSame('1.487,50', CreateIncomeSurplusStatementCommand::money(new Amount(1487.5, 'Euro'))); $this->assertSame('0,00', CreateIncomeSurplusStatementCommand::money(new Amount(0.0, 'Euro'))); $this->assertSame('-320,00', CreateIncomeSurplusStatementCommand::money(new Amount(-320.0, 'Euro'))); } public function test_the_route_delivers_a_pdf_and_does_not_hit_the_list_wildcard(): void { $event = $this->makeEvent(); $this->actingAs($this->makeAdmin()); $response = $this->get('/event/details/' . $event->identifier . '/pdf/income-surplus-statement'); $response->assertOk(); $response->assertHeader('Content-Type', 'application/pdf'); $this->assertStringContainsString('EUER-' . $event->identifier . '.pdf', $response->headers->get('Content-Disposition')); $this->assertStringStartsWith('%PDF', $response->getContent()); } /* |-------------------------------------------------------------------------- | Rückwirkung auf die Ausgabenübersicht |-------------------------------------------------------------------------- */ public function test_a_deleted_receipt_no_longer_counts_in_the_expense_overview(): void { // Dieselbe Regel wie in der EÜR: Ein gelöschter Beleg ist keine Ausgabe. Vor dem Fix zählte er in // `sumupByInvoiceType()` weiter und verfälschte damit Ausgabenübersicht und Bilanz. $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED); $amount = new CostUnitRepository()->sumupByInvoiceType( $this->costUnit->fresh(), InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first() ); $this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001); $this->assertEqualsWithDelta( 0.0, new CostUnitResource($this->costUnit->fresh())->toArray(true)['overAllAmount']['value']->getAmount(), 0.001 ); } /* |-------------------------------------------------------------------------- | Helfer |-------------------------------------------------------------------------- */ private function statement(): CreateIncomeSurplusStatementResponse { // `fresh()`, weil die DB-Vorgaben (Höchstbetrag) im frisch erzeugten Model noch nicht geladen sind // und EventResource sie als Amount erwartet. $event = Event::where('cost_unit_id', $this->costUnit->id)->first()->fresh(); $response = new CreateIncomeSurplusStatementCommand( new CreateIncomeSurplusStatementRequest($event) )->execute(); $this->assertTrue($response->success, $response->message ?? ''); return $response; } /** Der Betrag einer Unterzeile der Einnahmenseite, über alle Kategorien gesucht. */ private function entry(string $name): Amount { foreach ($this->statement()->income['categories'] as $category) { foreach ($category['entries'] as $entry) { if ($entry['name'] === $name) { return $entry['amount']; } } } $this->fail('Einnahmen-Zeile "' . $name . '" fehlt.'); } /** @return array{name: string, sum: Amount, rows: array>} */ private function group(string $name): array { foreach ($this->statement()->expenses['groups'] as $group) { if ($group['name'] === $name) { return $group; } } $this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.'); } 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, 'support_flat' => 1000.0, 'support_per_person' => 2.0, 'tax_liable' => false, 'vat_rate' => 0, 'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701', ]); } private function makeParticipant( Event $event, float $amount, float $amountPaid, bool $unregistered = false ): 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', 'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR, 'arrival_eating' => 1, 'departure_eating' => 1, 'amount' => $amount, 'amount_paid' => $amountPaid, 'unregistered_at' => $unregistered ? '2026-06-12' : null, 'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'payment_purpose' => 'Sommerlager', 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, ]); } private function makeInvoice( string $type, float $amount, string $status, bool $donation = false, ?string $typeOther = null, ?string $travelReason = null, ?string $purpose = null ): 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' => $status, 'type' => $type, 'type_other' => $typeOther, 'purpose' => $purpose, 'travel_reason' => $travelReason, 'donation' => $donation, 'contact_name' => 'Mika Muster', 'comment' => 'Materialkauf', 'amount' => $amount, ]); } private function makeAdmin(): User { return User::create([ 'username' => 'admin-' . uniqid() . '@example.com', 'email' => 'admin-' . uniqid() . '@example.com', 'firstname' => 'Test', 'lastname' => 'Person', 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => UserRole::USER_ROLE_USER, 'user_role_local_group' => UserRole::USER_ROLE_ADMIN, 'active' => true, ]); } }