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
+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());
}
}