Files
mareike/tests/Feature/InvoicePurposeTest.php
T
2026-09-07 14:17:50 +02:00

330 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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;
/**
* 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
{
use RefreshDatabase;
private Tenant $tenant;
private int $sequence = 0;
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']);
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());
}
}