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('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); foreach ([InvoiceStatus::INVOICE_STATUS_NEW, InvoiceStatus::INVOICE_STATUS_APPROVED] as $status) { DB::table('invoice_status')->insert(['slug' => $status]); } // Die Beitragserstattung bringt die Migration mit; die beiden anderen Typen nicht. foreach ([ InvoiceType::INVOICE_TYPE_TRAVELLING => 'Fahrtkosten', InvoiceType::INVOICE_TYPE_OTHER => 'Sonstige Kosten', ] as $slug => $name) { DB::table('invoice_types')->insert([ 'slug' => $slug, 'name' => $name, 'sort_order' => 1, 'selectable' => true, 'counts_as_expense' => true, ]); } foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { UserRole::create(['slug' => $role, 'name' => $role]); } Mail::fake(); } private function makeCostUnit(): CostUnit { return 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 makeUser(): User { return User::create([ 'username' => 'kassenwart-' . uniqid() . '@example.com', 'email' => 'kassenwart-' . uniqid() . '@example.com', 'firstname' => 'Kim', 'lastname' => 'Kasse', '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, ]); } /** * Ein Beleg direkt in der Datenbank -- so sehen Bestandsbelege aus, deren `purpose` leer ist. */ private function makeInvoice(array $attributes): Invoice { $this->sequence++; return Invoice::create(array_merge([ 'tenant' => $this->tenant->slug, 'cost_unit_id' => $this->makeCostUnit()->id, 'invoice_number' => sprintf('2026-%04d', $this->sequence), 'status' => InvoiceStatus::INVOICE_STATUS_NEW, 'contact_name' => 'Max Mustermann', 'amount' => 42.0, ], $attributes)); } private function purposeOf(array $attributes): string { return new InvoiceResource($this->makeInvoice($attributes))->toArray()['purpose']; } /* |-------------------------------------------------------------------------- | Erfasst beim Einreichen |-------------------------------------------------------------------------- */ /** * Der Weg, den jede eingereichte Abrechnung nimmt. `contactEmail` bleibt leer, damit kein * Bestätigungsmailversand mitläuft. */ private function submit(string $type, ?string $purchase = null, ?string $travelReason = null, ?string $travellers = null): Invoice { return new CreateInvoiceCommand(new CreateInvoiceRequest( costUnit: $this->makeCostUnit(), contactName: 'Max Mustermann', invoiceType: $type, totalAmount: 42.0, receiptFile: null, isDonation: false, invoiceTypeExtended: $purchase, travelReason: $travelReason, travellers: $travellers, ))->execute()->invoice; } public function test_an_expense_records_what_was_bought(): void { $this->assertSame( 'Bastelmaterial Sippenstunde', $this->submit(InvoiceType::INVOICE_TYPE_OTHER, purchase: 'Bastelmaterial Sippenstunde')->purpose ); } public function test_travel_costs_record_the_reason_and_who_travelled(): void { // Wer gefahren ist, wird gefragt und nicht aus dem Kontaktnamen geraten: Es kann jemand anderes // sein als der, auf dessen Konto das Geld geht. $invoice = $this->submit( InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager', travellers: 'Mika und Kim' ); $this->assertSame('Landeslager — Mika und Kim', $invoice->purpose); } public function test_travel_costs_without_travellers_record_only_the_reason(): void { // Ohne Login steht im Formular niemand drin -- das darf den Beleg nicht aufhalten. $invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager'); $this->assertSame('Landeslager', $invoice->purpose); } public function test_a_refund_records_no_purpose(): void { // Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie // dieselbe Regel wie für Bestandsbelege. $this->assertNull($this->submit(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->purpose); } /* |-------------------------------------------------------------------------- | Die erfasste Spalte gewinnt |-------------------------------------------------------------------------- */ public function test_a_recorded_purpose_beats_the_purchase_note(): void { $this->assertSame('Zeltplatzmiete', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial', 'purpose' => 'Zeltplatzmiete', ])); } public function test_a_recorded_purpose_beats_the_travel_derivation(): void { $this->assertSame('Bundeslager — Mika und Kim', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_TRAVELLING, 'travel_reason' => 'Landeslager', 'purpose' => 'Bundeslager — Mika und Kim', ])); } /* |-------------------------------------------------------------------------- | Bestandsbelege leiten weiter ab |-------------------------------------------------------------------------- */ public function test_an_expense_shows_what_was_bought(): void { $this->assertSame('Bastelmaterial Sippenstunde', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial Sippenstunde', ])); } public function test_travel_costs_show_the_reason_and_who_travelled(): void { // `type_other` bleibt bei Fahrtkosten leer -- die Strecke landet in `travel_direction`. Wer // gefahren ist, wurde damals nicht gefragt; dafür steht der Kontaktname. $this->assertSame('Landeslager — Max Mustermann', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_TRAVELLING, 'travel_direction' => 'Halle – Leipzig', 'travel_reason' => 'Landeslager', ])); } public function test_travel_costs_without_a_reason_still_name_the_person(): void { // Altbestand: der Reisegrund wurde erst später zur Pflicht. Kein führendes " — ". $this->assertSame('Max Mustermann', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_TRAVELLING, 'travel_direction' => 'Halle – Leipzig', ])); } public function test_an_invoice_without_a_purpose_stays_empty(): void { // Beitragserstattungen entstehen ohne Freitext, ältere Belege haben keinen. Ein "--" würde in // der Liste nur Platz kosten. $this->assertSame('', $this->purposeOf([ 'type' => InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, ])); } /* |-------------------------------------------------------------------------- | Korrigierbar, und danach unangetastet |-------------------------------------------------------------------------- */ /** * Der Korrektur-Weg der Kassenwart*in. `UpdateInvoiceCommand` gibt die Abrechnung am Ende frei -- * dafür braucht es einen eingeloggten Menschen. */ private function correct(Invoice $invoice, ?string $purpose): Invoice { $this->actingAs($this->makeUser()); new UpdateInvoiceCommand(new UpdateInvoiceRequest( $invoice, $invoice->comment, $invoice->invoiceType(), $invoice->costUnit()->first(), Amount::fromString($invoice->amount), $purpose ))->execute(); return $invoice->fresh(); } public function test_a_corrected_purpose_is_stored_and_logged(): void { $invoice = $this->correct( $this->makeInvoice(['type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial']), 'Material für den Bastelnachmittag' ); $this->assertSame('Material für den Bastelnachmittag', $invoice->purpose); $this->assertSame('Material für den Bastelnachmittag', $invoice->purposeText()); $this->assertStringContainsString('Zahlungsgrund geändert', $invoice->changes); } public function test_an_untouched_purpose_is_not_frozen(): void { // Das Formular ist mit dem angezeigten Text vorbelegt. Wer ihn unverändert abschickt, hat nichts // geändert -- ein Bestandsbeleg behält seine leere Spalte und leitet weiter ab. $invoice = $this->correct( $this->makeInvoice(['type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial']), 'Bastelmaterial' ); $this->assertNull($invoice->purpose); $this->assertSame('Bastelmaterial', $invoice->purposeText()); $this->assertStringNotContainsString('Zahlungsgrund', (string) $invoice->changes); } public function test_the_comment_of_a_correction_stays_out_of_the_purpose(): void { // Die Anmerkung der Korrektur gehört auf den Beleg, nicht in den Zahlungsgrund. $invoice = $this->makeInvoice([ 'type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial', 'comment' => 'Betrag nach Rücksprache korrigiert', ]); $this->assertSame('Bastelmaterial', $this->correct($invoice, 'Bastelmaterial')->purposeText()); } }