325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Enumerations\CostUnitType;
|
|
use App\Enumerations\EfzStatus;
|
|
use App\Enumerations\InvoiceStatus;
|
|
use App\Enumerations\InvoiceType;
|
|
use App\Models\CostUnit;
|
|
use App\Models\Event;
|
|
use App\Models\Invoice;
|
|
use App\Models\PaymentMethod;
|
|
use App\Models\Tenant;
|
|
use App\RelationModels\EventParticipationFee;
|
|
use App\Resources\CostUnitResource;
|
|
use App\Resources\EventResource;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Die Ausgabenrechnung der Veranstaltungsübersicht.
|
|
*
|
|
* Eine Beitragserstattung gehört dort nicht hinein: Sie ist die Rücknahme einer Einnahme, und die
|
|
* Einnahmenseite hat sie bereits berücksichtigt -- ein abgemeldeter Teili fällt aus `getParticipants()`
|
|
* heraus. Als Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
|
|
*/
|
|
class EventBudgetTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Tenant $tenant;
|
|
|
|
private CostUnit $costUnit;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->tenant = Tenant::create([
|
|
'slug' => 'wm',
|
|
'name' => 'Wilde Möhre',
|
|
'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('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
|
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
|
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
|
|
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
|
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
|
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
|
|
|
// Ein gewöhnlicher Aufwandstyp zum Vergleich; die Beitragserstattung bringt die Migration mit.
|
|
DB::table('invoice_types')->insert([
|
|
'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
|
|
'name' => 'Programmkosten',
|
|
'sort_order' => 1,
|
|
'selectable' => true,
|
|
'counts_as_expense' => true,
|
|
]);
|
|
|
|
$this->costUnit = 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 makeEvent(): Event
|
|
{
|
|
$fee = EventParticipationFee::create([
|
|
'tenant' => $this->tenant->slug,
|
|
'type' => 'participant',
|
|
'name' => 'Sippe',
|
|
'description' => null,
|
|
'amount_standard' => 60.0,
|
|
'amount_reduced' => null,
|
|
'amount_solidarity' => null,
|
|
]);
|
|
|
|
return Event::create([
|
|
'cost_unit_id' => $this->costUnit->id,
|
|
'tenant' => $this->tenant->slug,
|
|
'name' => 'Sommerlager',
|
|
'identifier' => 'evt-' . uniqid(),
|
|
'location' => 'Ort',
|
|
'postal_code' => '00000',
|
|
'email' => 'e@example.com',
|
|
'start_date' => '2026-07-16',
|
|
'end_date' => '2026-07-20',
|
|
'early_bird_end' => '2026-06-20',
|
|
'registration_final_end' => '2026-07-01',
|
|
'early_bird_end_amount_increase' => 0,
|
|
'account_owner' => 'Owner',
|
|
'account_iban' => 'DE00',
|
|
'participation_fee_type' => 'fixed',
|
|
'participation_fee_1' => $fee->id,
|
|
'pay_per_day' => true,
|
|
'pay_direct' => false,
|
|
'tax_liable' => false,
|
|
'vat_rate' => 0,
|
|
'vat_pricing_mode' => 'inclusive',
|
|
'invoice_key' => 'WM-V-20260701',
|
|
]);
|
|
}
|
|
|
|
private function makeInvoice(string $type, float $amount): Invoice
|
|
{
|
|
return Invoice::create([
|
|
'tenant' => $this->tenant->slug,
|
|
'cost_unit_id' => $this->costUnit->id,
|
|
'invoice_number' => '2026-' . str_pad((string) Invoice::count() + 1, 4, '0', STR_PAD_LEFT),
|
|
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
|
|
'type' => $type,
|
|
'donation' => false,
|
|
'contact_name' => 'Mika Muster',
|
|
'amount' => $amount,
|
|
]);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function costUnitData(): array
|
|
{
|
|
return new CostUnitResource($this->costUnit->fresh())->toArray(true);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Die Ausgabenliste
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
public function test_a_refund_does_not_appear_as_an_expense_row(): void
|
|
{
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
|
|
|
$amounts = $this->costUnitData()['amounts'];
|
|
|
|
$this->assertArrayNotHasKey(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $amounts);
|
|
$this->assertArrayHasKey(InvoiceType::INVOICE_TYPE_PROGRAM, $amounts);
|
|
}
|
|
|
|
public function test_a_refund_is_not_part_of_the_expense_total(): void
|
|
{
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
|
|
|
$this->assertEqualsWithDelta(0.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
|
|
}
|
|
|
|
public function test_an_ordinary_invoice_of_the_same_amount_does_count(): void
|
|
{
|
|
// Gegenprobe: Es liegt am Typ, nicht am Betrag.
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
|
|
|
|
$this->assertEqualsWithDelta(220.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
|
|
}
|
|
|
|
public function test_the_cash_view_still_shows_the_refund(): void
|
|
{
|
|
// `totalAmount` speist Kostenstellen-Liste und Dashboard -- dort fließt das Geld tatsächlich ab.
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
|
|
|
$this->assertStringContainsString('220,00', $this->costUnitData()['totalAmount']);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Die Bilanz der Veranstaltung
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
public function test_the_balance_is_not_reduced_by_a_refund(): void
|
|
{
|
|
// `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
|
|
// geladen sind und EventResource sie als Amount erwartet.
|
|
$event = $this->makeEvent()->fresh();
|
|
$before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
|
|
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
|
|
|
$after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
|
|
|
|
$this->assertEqualsWithDelta($before, $after, 0.001);
|
|
}
|
|
|
|
public function test_the_balance_is_reduced_by_an_ordinary_invoice(): void
|
|
{
|
|
// `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
|
|
// geladen sind und EventResource sie als Amount erwartet.
|
|
$event = $this->makeEvent()->fresh();
|
|
$before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
|
|
|
|
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
|
|
|
|
$after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
|
|
|
|
$this->assertEqualsWithDelta($before - 220.0, $after, 0.001);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Beiträge abgemeldeter Teilis
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
public function test_money_left_by_unregistered_participants_counts_as_income(): void
|
|
{
|
|
$event = $this->makeEvent();
|
|
$before = new EventResource($event->fresh())->toArray(new Request());
|
|
|
|
// 80 € sind nach einer Teilerstattung beim Verband geblieben.
|
|
$this->makeUnregisteredParticipant($event, 80.0);
|
|
|
|
$after = new EventResource($event->fresh())->toArray(new Request());
|
|
|
|
$this->assertEqualsWithDelta(80.0, $after['retainedFromUnregistered']['value'], 0.001);
|
|
|
|
// In beide Spalten: Das Geld ist da (real) und fließt nicht mehr ab (erwartet).
|
|
$this->assertEqualsWithDelta(
|
|
$before['income']['real']['amount']->getAmount() + 80.0,
|
|
$after['income']['real']['amount']->getAmount(),
|
|
0.001
|
|
);
|
|
$this->assertEqualsWithDelta(
|
|
$before['income']['expected']['amount']->getAmount() + 80.0,
|
|
$after['income']['expected']['amount']->getAmount(),
|
|
0.001
|
|
);
|
|
$this->assertEqualsWithDelta(
|
|
$before['totalBalance']['real']['value'] + 80.0,
|
|
$after['totalBalance']['real']['value'],
|
|
0.001
|
|
);
|
|
}
|
|
|
|
public function test_unregistered_participants_stay_out_of_lists_and_counts(): void
|
|
{
|
|
$event = $this->makeEvent();
|
|
$this->makeUnregisteredParticipant($event, 80.0);
|
|
|
|
$participantData = new EventResource($event->fresh())
|
|
->toArray(new Request())['participants']['participant'];
|
|
|
|
// Nur die Geldsumme kommt hinzu -- in Listen und Zahlen bleiben Abgemeldete außen vor.
|
|
$this->assertSame(0, $participantData['count']);
|
|
$this->assertArrayNotHasKey('participants', $participantData);
|
|
$this->assertEqualsWithDelta(0.0, $participantData['amount']['paid']['value'], 0.001);
|
|
}
|
|
|
|
public function test_the_funding_does_not_grow_with_unregistered_participants(): void
|
|
{
|
|
$event = $this->makeEvent();
|
|
$before = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
|
|
|
|
$this->makeUnregisteredParticipant($event, 80.0);
|
|
|
|
$after = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
|
|
|
|
// Die wichtigste Gegenprobe: Wer nicht da war, bringt keine Förderung.
|
|
$this->assertEqualsWithDelta($before, $after, 0.001);
|
|
}
|
|
|
|
public function test_the_row_is_hidden_when_nothing_was_retained(): void
|
|
{
|
|
$event = $this->makeEvent();
|
|
$this->makeUnregisteredParticipant($event, 0.0);
|
|
|
|
// Die Zeile in der Übersicht hängt an diesem Wert -- bei 0 soll sie nicht erscheinen.
|
|
$this->assertEqualsWithDelta(
|
|
0.0,
|
|
new EventResource($event->fresh())->toArray(new Request())['retainedFromUnregistered']['value'],
|
|
0.001
|
|
);
|
|
}
|
|
|
|
/** Ein abgemeldeter Teili, bei dem der übergebene Betrag beim Verband geblieben ist. */
|
|
private function makeUnregisteredParticipant(Event $event, float $remaining): void
|
|
{
|
|
$event->participants()->create([
|
|
'tenant' => $this->tenant->slug,
|
|
'identifier' => 'p-' . uniqid(),
|
|
'invoice_sequence' => $event->participants()->count() + 1,
|
|
'firstname' => 'Mika',
|
|
'lastname' => 'Muster',
|
|
'participation_type' => 'participant',
|
|
'fee_type' => 'standard',
|
|
'sibling_reduction' => false,
|
|
'local_group' => $this->tenant->slug,
|
|
'birthday' => '2000-01-01',
|
|
'address_1' => 'Beispielstraße 3',
|
|
'postcode' => '11111',
|
|
'city' => 'Beispielstadt',
|
|
'email_1' => 'mika@example.com',
|
|
'phone_1' => '0170 0000000',
|
|
'arrival_date' => '2026-07-16',
|
|
'departure_date' => '2026-07-20',
|
|
'arrival_eating' => 1,
|
|
'departure_eating' => 1,
|
|
'amount' => 300.0,
|
|
'amount_paid' => $remaining,
|
|
'unregistered_at' => '2026-06-12',
|
|
'payment_purpose' => 'Sommerlager',
|
|
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
|
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
|
]);
|
|
}
|
|
}
|