Zahlungszwecke

This commit is contained in:
2026-09-07 14:17:50 +02:00
parent 350c8dd0d0
commit 40d5634764
27 changed files with 927 additions and 205 deletions
@@ -260,7 +260,26 @@ class EventIncomeSurplusStatementTest extends TestCase
typeOther: 'Bastelmaterial'
);
$this->assertSame('Bastelmaterial — Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
$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
@@ -286,16 +305,18 @@ class EventIncomeSurplusStatementTest extends TestCase
travelReason: 'Landeslager'
);
$this->assertSame('Landeslager — Mika Muster — Materialkauf', $this->group('Fahrtkosten')['rows'][0]['purpose']);
$this->assertSame('Landeslager — Mika Muster', $this->group('Fahrtkosten')['rows'][0]['purpose']);
}
public function test_an_older_receipt_without_the_purchase_note_falls_back_to_the_comment(): void
public function test_the_comment_is_no_longer_part_of_the_purpose(): void
{
// Belege von vor der Pflichtangabe haben `type_other` leer.
// 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('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
$this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']);
}
/*
@@ -498,7 +519,8 @@ class EventIncomeSurplusStatementTest extends TestCase
string $status,
bool $donation = false,
?string $typeOther = null,
?string $travelReason = null
?string $travelReason = null,
?string $purpose = null
): Invoice {
return Invoice::create([
'tenant' => $this->tenant->slug,
@@ -507,6 +529,7 @@ class EventIncomeSurplusStatementTest extends TestCase
'status' => $status,
'type' => $type,
'type_other' => $typeOther,
'purpose' => $purpose,
'travel_reason' => $travelReason,
'donation' => $donation,
'contact_name' => 'Mika Muster',
+204 -8
View File
@@ -2,20 +2,30 @@
namespace Tests\Feature;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceCommand;
use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\Tenant;
use App\Models\User;
use App\Resources\InvoiceResource;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Die Spalte "Zahlungsgrund" der Beleglisten. Sie liest nicht ein Feld, sondern das, was der
* Einreiche-Flow je Abrechnungstyp gefüllt hat -- bei Fahrtkosten ist `type_other` leer.
* Der Zahlungsgrund: die gleichnamige Spalte der Beleglisten und der "Zweck" der EüR-Anlage.
*
* Er wird beim Einreichen erfasst und steht danach in einer eigenen Spalte -- eine Angabe, keine
* Ableitung. Nur Bestandsbelege, die vor der Spalte entstanden sind, leiten ihn noch her.
*/
class InvoicePurposeTest extends TestCase
{
@@ -49,7 +59,10 @@ class InvoicePurposeTest extends TestCase
app()->instance('tenant', $this->tenant);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
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 ([
@@ -64,6 +77,12 @@ class InvoicePurposeTest extends TestCase
'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
@@ -79,11 +98,29 @@ class InvoicePurposeTest extends TestCase
]);
}
private function purposeOf(array $attributes): string
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++;
$invoice = Invoice::create(array_merge([
return Invoice::create(array_merge([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => sprintf('2026-%04d', $this->sequence),
@@ -91,10 +128,104 @@ class InvoicePurposeTest extends TestCase
'contact_name' => 'Max Mustermann',
'amount' => 42.0,
], $attributes));
return new InvoiceResource($invoice)->toArray()['purpose'];
}
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([
@@ -105,7 +236,8 @@ class InvoicePurposeTest extends TestCase
public function test_travel_costs_show_the_reason_and_who_travelled(): void
{
// `type_other` bleibt bei Fahrtkosten leer -- die Strecke landet in `travel_direction`.
// `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',
@@ -130,4 +262,68 @@ class InvoicePurposeTest extends TestCase
'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());
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace Tests\Feature;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceResponse;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\TravelReason;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\Tenant;
use App\Resources\InvoiceResource;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Reisegrund ist eine Auswahl, kein Freitext mehr.
*
* Gespeichert wird der Schlüssel des Grundes -- außer bei "Anderer Grund": Der Schlüssel `other` sagt
* für sich nichts aus, deshalb steht dort der Text. Dieselbe Spalte führt damit weiter den Freitext der
* Bestandsbelege, ohne dass etwas umgeschrieben werden musste.
*/
class TravelReasonTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->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']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
DB::table('invoice_types')->insert([
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'name' => 'Fahrtkosten',
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
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,
]);
}
/**
* Der Weg, den eine eingereichte Fahrtkostenabrechnung nimmt. `contactEmail` bleibt leer, damit kein
* Bestätigungsmailversand mitläuft.
*/
private function submit(?string $reason, ?string $note = null, ?string $travellers = 'Mika und Kim'): CreateInvoiceResponse
{
return new CreateInvoiceCommand(new CreateInvoiceRequest(
costUnit: $this->makeCostUnit(),
contactName: 'Max Mustermann',
invoiceType: InvoiceType::INVOICE_TYPE_TRAVELLING,
totalAmount: 42.0,
receiptFile: null,
isDonation: false,
travelRoute: 'Halle Leipzig',
travelReason: $reason,
travelReasonNote: $note,
travellers: $travellers,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Die Auswahl
|--------------------------------------------------------------------------
*/
public function test_the_options_are_ordered_and_only_the_last_one_needs_a_note(): void
{
$options = TravelReason::options();
$this->assertSame(
['event_travel', 'material_transport', 'purchase', 'other'],
array_column($options, 'value')
);
$this->assertSame('An-/Abreise zur Veranstaltung', $options[0]['label']);
$this->assertSame([false, false, false, true], array_column($options, 'requiresNote'));
}
/*
|--------------------------------------------------------------------------
| Was gespeichert wird
|--------------------------------------------------------------------------
*/
public function test_a_fixed_reason_is_stored_as_its_key(): void
{
$invoice = $this->submit(TravelReason::MATERIAL_TRANSPORT)->invoice;
$this->assertSame(TravelReason::MATERIAL_TRANSPORT, $invoice->travel_reason);
}
public function test_the_purpose_carries_the_name_not_the_key(): void
{
// In der Belegliste soll "Materialtransport" stehen, nicht "material_transport".
$invoice = $this->submit(TravelReason::MATERIAL_TRANSPORT)->invoice;
$this->assertSame('Materialtransport — Mika und Kim', $invoice->purpose);
}
public function test_another_reason_stores_the_text_instead_of_the_key(): void
{
$invoice = $this->submit(TravelReason::OTHER, 'Abholung der Ausrüstung')->invoice;
$this->assertSame('Abholung der Ausrüstung', $invoice->travel_reason);
$this->assertSame('Abholung der Ausrüstung — Mika und Kim', $invoice->purpose);
}
public function test_another_reason_without_a_text_creates_nothing(): void
{
// Sicherheitsnetz hinter der Oberfläche: Dort geht es ohne Erläuterung nicht weiter.
$response = $this->submit(TravelReason::OTHER, ' ');
$this->assertFalse($response->success);
$this->assertNull($response->invoice);
$this->assertSame(0, Invoice::count());
$this->assertNotNull($response->message);
}
public function test_an_unknown_value_passes_through_unchanged(): void
{
// Bestandsbelege und ihre Kopien bringen ihren Freitext schon mit.
$invoice = $this->submit('Fahrt zum Landeslager')->invoice;
$this->assertSame('Fahrt zum Landeslager', $invoice->travel_reason);
$this->assertSame('Fahrt zum Landeslager — Mika und Kim', $invoice->purpose);
}
/*
|--------------------------------------------------------------------------
| Was angezeigt wird
|--------------------------------------------------------------------------
*/
public function test_a_key_is_shown_as_its_name(): void
{
$invoice = $this->submit(TravelReason::EVENT_TRAVEL)->invoice;
$this->assertSame('An-/Abreise zur Veranstaltung', $invoice->travelReasonText());
$this->assertSame(
'An-/Abreise zur Veranstaltung',
new InvoiceResource($invoice)->toArray()['travelReason']
);
}
public function test_free_text_is_shown_as_itself(): void
{
$invoice = $this->submit('Fahrt zum Landeslager')->invoice;
$this->assertSame('Fahrt zum Landeslager', $invoice->travelReasonText());
}
}