From 350c8dd0d0559af8ce008326f4d46eb8b6edde64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Mon, 7 Sep 2026 12:10:12 +0200
Subject: [PATCH] better handling payment purpose
---
.../CostUnit/Views/Partials/ListInvoices.vue | 7 +-
.../CreateIncomeSurplusStatementCommand.php | 19 +--
.../myInvoiceDetails/ListInvoices.vue | 5 +-
app/Models/Invoice.php | 21 +++
app/Resources/InvoiceResource.php | 1 +
.../EventIncomeSurplusStatementTest.php | 30 +++-
tests/Feature/InvoicePurposeTest.php | 133 ++++++++++++++++++
7 files changed, 202 insertions(+), 14 deletions(-)
create mode 100644 tests/Feature/InvoicePurposeTest.php
diff --git a/app/Domains/CostUnit/Views/Partials/ListInvoices.vue b/app/Domains/CostUnit/Views/Partials/ListInvoices.vue
index 2673fad..2933661 100644
--- a/app/Domains/CostUnit/Views/Partials/ListInvoices.vue
+++ b/app/Domains/CostUnit/Views/Partials/ListInvoices.vue
@@ -89,12 +89,13 @@
- | {{props.data.costUnit.name}} |
+ {{props.data.costUnit.name}} |
| {{invoice.invoiceNumber}} |
- {{invoice.invoiceType}} |
+ {{invoice.invoiceTypeShort}} |
+ {{invoice.purpose}} |
{{invoice.amount}}
|
@@ -114,7 +115,7 @@
- |
+ |
Genehmigte Abrechnungen exportieren
|
diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
index 987f089..824666a 100644
--- a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
+++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
@@ -148,7 +148,7 @@ class CreateIncomeSurplusStatementCommand
$rows[] = [
'number' => (string) $invoice->invoice_number,
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
- 'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
+ 'purpose' => $this->purpose($invoice->purposeText(), $invoice->comment),
'amount' => Amount::fromString($invoice->amount),
];
}
@@ -166,18 +166,21 @@ class CreateIncomeSurplusStatementCommand
}
/**
- * Wofür der Beleg steht.
+ * Wofür der Beleg steht, um die Anmerkung ergänzt.
*
- * `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
- * nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
- * Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
- * Belegliste nur Platz kosten.
+ * Den Zweck selbst bestimmt {@see \App\Models\Invoice::purposeText()} -- dieselbe Ermittlung wie in
+ * der Beleg-Übersicht, damit ein Beleg nicht an zwei Stellen Verschiedenes über sich behauptet. Die
+ * Anmerkung kommt nur hier dazu: Auf der Aufstellung steht der Beleg für sich, ohne die Detailansicht
+ * daneben.
+ *
+ * Ältere Belege haben keinen Zweck erfasst -- dann bleibt die Anmerkung, und fehlt auch die, bleibt
+ * die Zelle leer. Ein Platzhalter wie "--" würde in der Belegliste nur Platz kosten.
*/
- private function purpose(?string $typeOther, ?string $comment): string
+ private function purpose(?string $purpose, ?string $comment): string
{
$parts = [];
- foreach ([$typeOther, $comment] as $part) {
+ foreach ([$purpose, $comment] as $part) {
if (trim((string) $part) !== '') {
$parts[] = trim((string) $part);
}
diff --git a/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue b/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
index 43c3481..8deae6e 100644
--- a/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
+++ b/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
@@ -45,12 +45,13 @@
- | {{props.data.title}} |
+ {{props.data.title}} |
| {{invoice.invoiceNumber}} |
- {{invoice.invoiceType}} |
+ {{invoice.invoiceTypeShort}} |
+ {{invoice.purpose}} |
{{invoice.amount}}
|
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
index ddfa818..48b5c53 100644
--- a/app/Models/Invoice.php
+++ b/app/Models/Invoice.php
@@ -91,6 +91,27 @@ class Invoice extends InstancedModel
return $subject . ' Belegnummer ' . $this->invoice_number;
}
+ /**
+ * Wofür der Beleg steht -- der "Zahlungsgrund" der Beleglisten und der "Zweck" der EüR-Anlage.
+ *
+ * `type_other` ("Was wurde eingekauft") trägt seit der Pflichtangabe zu jeder Abrechnung den Zweck.
+ * Bei Fahrtkosten bleibt das Feld leer: dort schreibt der Einreiche-Flow die Strecke nach
+ * `travel_direction`. Den Zweck trägt dann der Reisegrund, ergänzt um den Namen der reisenden
+ * Person -- bei einer Fahrt ist "wer" Teil der Begründung, nicht bloß Kontaktangabe.
+ *
+ * Ältere Belege und Beitragserstattungen haben nichts davon gesetzt; dann bleibt der Text leer.
+ * Ein "--" würde in einer Belegliste nur Platz kosten.
+ */
+ public function purposeText() : string {
+ $parts = $this->type === InvoiceType::INVOICE_TYPE_TRAVELLING
+ ? [$this->travel_reason, $this->contact_name]
+ : [$this->type_other];
+
+ $parts = array_map(fn ($part) => trim((string) $part), $parts);
+
+ return implode(' — ', array_filter($parts, fn ($part) => $part !== ''));
+ }
+
public function costUnit() : BelongsTo{
return $this->belongsTo(CostUnit::class);
}
diff --git a/app/Resources/InvoiceResource.php b/app/Resources/InvoiceResource.php
index 59810b7..db61dc7 100644
--- a/app/Resources/InvoiceResource.php
+++ b/app/Resources/InvoiceResource.php
@@ -30,6 +30,7 @@ class InvoiceResource {
}
$returnData['invoiceTypeShort'] = $this->invoice->invoiceType()->name;
+ $returnData['purpose'] = $this->invoice->purposeText();
$returnData['costUnitName'] = $this->invoice->costUnit()->first()->name;
$returnData['invoiceNumber'] = $this->invoice->invoice_number;
$returnData['contactName'] = $this->invoice->contact_name;
diff --git a/tests/Feature/EventIncomeSurplusStatementTest.php b/tests/Feature/EventIncomeSurplusStatementTest.php
index 033b639..ceb7a36 100644
--- a/tests/Feature/EventIncomeSurplusStatementTest.php
+++ b/tests/Feature/EventIncomeSurplusStatementTest.php
@@ -263,6 +263,32 @@ class EventIncomeSurplusStatementTest extends TestCase
$this->assertSame('Bastelmaterial — Materialkauf', $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 — Materialkauf', $this->group('Fahrtkosten')['rows'][0]['purpose']);
+ }
+
public function test_an_older_receipt_without_the_purchase_note_falls_back_to_the_comment(): void
{
// Belege von vor der Pflichtangabe haben `type_other` leer.
@@ -471,7 +497,8 @@ class EventIncomeSurplusStatementTest extends TestCase
float $amount,
string $status,
bool $donation = false,
- ?string $typeOther = null
+ ?string $typeOther = null,
+ ?string $travelReason = null
): Invoice {
return Invoice::create([
'tenant' => $this->tenant->slug,
@@ -480,6 +507,7 @@ class EventIncomeSurplusStatementTest extends TestCase
'status' => $status,
'type' => $type,
'type_other' => $typeOther,
+ 'travel_reason' => $travelReason,
'donation' => $donation,
'contact_name' => 'Mika Muster',
'comment' => 'Materialkauf',
diff --git a/tests/Feature/InvoicePurposeTest.php b/tests/Feature/InvoicePurposeTest.php
new file mode 100644
index 0000000..2951a3e
--- /dev/null
+++ b/tests/Feature/InvoicePurposeTest.php
@@ -0,0 +1,133 @@
+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]);
+
+ // 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,
+ ]);
+ }
+ }
+
+ 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 purposeOf(array $attributes): string
+ {
+ $this->sequence++;
+
+ $invoice = 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));
+
+ return new InvoiceResource($invoice)->toArray()['purpose'];
+ }
+
+ 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`.
+ $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,
+ ]));
+ }
+}