330 lines
12 KiB
PHP
330 lines
12 KiB
PHP
<?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());
|
||
}
|
||
}
|