Abrechnungsgrüne nun immer erforderlich

This commit is contained in:
2026-09-04 02:11:43 +02:00
parent 15690b1da9
commit d23e6c3914
10 changed files with 959 additions and 33 deletions
@@ -0,0 +1,504 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementCommand;
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementRequest;
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementResponse;
use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\SwimmingPermission;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Event;
use App\Models\Invoice;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\Repositories\CostUnitRepository;
use App\Resources\CostUnitResource;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Die Einnahmen-Überschuss-Rechnung einer Veranstaltung.
*
* Sie zeigt ausschließlich Geld, das geflossen ist. Was nur erwartet wird (offene Beiträge) oder geplant
* ist (Budgetwerte), gehört nicht hinein -- ebenso wenig ein Beleg, der abgelehnt, gelöscht oder gespendet
* wurde.
*/
class EventIncomeSurplusStatementTest 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']);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
// Aktive Anmeldungen laufen für die Fördertage durch `EventParticipantResource`, und die liest
// Erlaubnisse und Essgewohnheit ohne Null-Prüfung.
EatingHabit::create([
'slug' => EatingHabit::EATING_HABIT_OMNIVOR,
'name' => 'Alles',
]);
SwimmingPermission::create([
'slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'name' => 'Erlaubt',
'short' => 'ja',
]);
FirstAidPermission::create([
'slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'name' => 'Erlaubt',
'description' => 'Erlaubt',
]);
foreach ([
InvoiceStatus::INVOICE_STATUS_NEW,
InvoiceStatus::INVOICE_STATUS_APPROVED,
InvoiceStatus::INVOICE_STATUS_EXPORTED,
InvoiceStatus::INVOICE_STATUS_DENIED,
InvoiceStatus::INVOICE_STATUS_DELETED,
] as $status) {
DB::table('invoice_status')->insert(['slug' => $status]);
}
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
// Zwei gewöhnliche Aufwandstypen plus einer, der leer bleibt; die Beitragserstattung bringt die
// Migration mit.
foreach ([
[InvoiceType::INVOICE_TYPE_PROGRAM, 'Programmkosten', 1],
[InvoiceType::INVOICE_TYPE_CATERING, 'Verpflegungskosten', 1],
[InvoiceType::INVOICE_TYPE_OTHER, 'Sonstige Kosten', 3],
] as [$slug, $name, $sortOrder]) {
DB::table('invoice_types')->insert([
'slug' => $slug,
'name' => $name,
'sort_order' => $sortOrder,
'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,
]);
}
/*
|--------------------------------------------------------------------------
| Einnahmen
|--------------------------------------------------------------------------
*/
public function test_only_paid_contributions_count(): void
{
$event = $this->makeEvent();
$this->makeParticipant($event, amount: 300.0, amountPaid: 300.0);
$this->makeParticipant($event, amount: 300.0, amountPaid: 0.0);
// 600 € wurden erwartet, 300 € sind angekommen -- die EÜR kennt nur die 300.
$this->assertEqualsWithDelta(300.0, $this->entry('Teilnahmebeiträge')->getAmount(), 0.001);
}
public function test_money_retained_from_unregistered_participants_is_its_own_line(): void
{
$event = $this->makeEvent();
$this->makeParticipant($event, amount: 300.0, amountPaid: 300.0);
$this->makeParticipant($event, amount: 300.0, amountPaid: 80.0, unregistered: true);
// Der Rest einer Teilerstattung liegt beim Verband, gehört aber nicht zu den Beiträgen: Dort
// stehen nur aktive Anmeldungen.
$this->assertEqualsWithDelta(300.0, $this->entry('Teilnahmebeiträge')->getAmount(), 0.001);
$this->assertEqualsWithDelta(80.0, $this->entry('Einbehaltene Einnahmen aus Abmeldungen')->getAmount(), 0.001);
}
public function test_the_two_income_categories_add_up(): void
{
$event = $this->makeEvent();
$this->makeParticipant($event, amount: 300.0, amountPaid: 300.0);
$this->makeParticipant($event, amount: 300.0, amountPaid: 80.0, unregistered: true);
$income = $this->statement()->income;
// Eigenmittel: 300 Beiträge + 1000 weitere Einnahmen + 80 einbehalten.
$this->assertSame('Eigenmittel', $income['categories'][0]['name']);
$this->assertEqualsWithDelta(1000.0, $this->entry('Weitere Einnahmen')->getAmount(), 0.001);
$this->assertEqualsWithDelta(1380.0, $income['categories'][0]['total']->getAmount(), 0.001);
// Förderungen: 2,00 € p.P./Tag über 4 Fördertage der einen aktiven Anmeldung.
$this->assertSame('Förderungen', $income['categories'][1]['name']);
$this->assertEqualsWithDelta(8.0, $income['categories'][1]['total']->getAmount(), 0.001);
$this->assertEqualsWithDelta(1388.0, $income['total']->getAmount(), 0.001);
}
public function test_the_funding_line_names_the_rate(): void
{
$this->makeEvent();
$funding = $this->statement()->income['categories'][1]['entries'][0];
$this->assertSame('Fördermittel (2,00 € p.P./Tag)', $funding['name']);
}
/*
|--------------------------------------------------------------------------
| Ausgaben
|--------------------------------------------------------------------------
*/
public function test_only_receipts_representing_real_money_count(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 50.0, InvoiceStatus::INVOICE_STATUS_APPROVED);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 30.0, InvoiceStatus::INVOICE_STATUS_EXPORTED);
// Kein Geldfluss, also keine Ausgabe:
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->assertEqualsWithDelta(150.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(30.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(180.0, $this->statement()->expenses['total']->getAmount(), 0.001);
}
public function test_a_refund_is_no_expense(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED);
$expenses = $this->statement()->expenses;
$this->assertEqualsWithDelta(0.0, $expenses['total']->getAmount(), 0.001);
$this->assertSame(
[],
array_values(array_filter($expenses['groups'], fn ($group) => $group['name'] === 'Beitragserstattung'))
);
}
public function test_every_expense_type_gets_a_row_even_without_receipts(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$groups = $this->statement()->expenses['groups'];
// Die Gliederung bleibt vollständig -- eine fehlende Zeile ließe sich sonst mit einer vergessenen
// verwechseln.
$this->assertSame(
['Programmkosten', 'Verpflegungskosten', 'Sonstige Kosten'],
array_column($groups, 'name')
);
$this->assertEqualsWithDelta(0.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001);
}
public function test_a_type_without_receipts_has_no_attachment_rows(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertCount(1, $this->group('Programmkosten')['rows']);
$this->assertCount(0, $this->group('Verpflegungskosten')['rows']);
}
public function test_the_attachment_names_the_purpose_for_every_type(): void
{
// "Was wurde eingekauft" wird zu jeder Abrechnung erfasst, nicht nur zu "Sonstige Kosten" --
// dadurch trägt die Zweck-Spalte auch bei Programmkosten eine Aussage.
$this->makeEvent();
$this->makeInvoice(
InvoiceType::INVOICE_TYPE_PROGRAM,
100.0,
InvoiceStatus::INVOICE_STATUS_EXPORTED,
typeOther: 'Bastelmaterial'
);
$this->assertSame('Bastelmaterial — Materialkauf', $this->group('Programmkosten')['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.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertSame('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
}
/*
|--------------------------------------------------------------------------
| Ergebnis und Auslieferung
|--------------------------------------------------------------------------
*/
public function test_the_result_is_income_minus_expenses(): void
{
$event = $this->makeEvent();
$this->makeParticipant($event, amount: 300.0, amountPaid: 300.0);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$statement = $this->statement();
$this->assertEqualsWithDelta(
$statement->income['total']->getAmount() - $statement->expenses['total']->getAmount(),
$statement->result->getAmount(),
0.001
);
}
public function test_amounts_are_printed_in_german_notation(): void
{
// `Amount::getFormattedAmount()` ersetzt jeden Punkt durch ein Komma und macht aus 1.487,50 ein
// "1,487,50". Auf einer Aufstellung mit vierstelligen Beträgen wäre das nicht lesbar.
$this->assertSame('1.487,50', CreateIncomeSurplusStatementCommand::money(new Amount(1487.5, 'Euro')));
$this->assertSame('0,00', CreateIncomeSurplusStatementCommand::money(new Amount(0.0, 'Euro')));
$this->assertSame('-320,00', CreateIncomeSurplusStatementCommand::money(new Amount(-320.0, 'Euro')));
}
public function test_the_route_delivers_a_pdf_and_does_not_hit_the_list_wildcard(): void
{
$event = $this->makeEvent();
$this->actingAs($this->makeAdmin());
$response = $this->get('/event/details/' . $event->identifier . '/pdf/income-surplus-statement');
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$this->assertStringContainsString('EUER-' . $event->identifier . '.pdf', $response->headers->get('Content-Disposition'));
$this->assertStringStartsWith('%PDF', $response->getContent());
}
/*
|--------------------------------------------------------------------------
| Rückwirkung auf die Ausgabenübersicht
|--------------------------------------------------------------------------
*/
public function test_a_deleted_receipt_no_longer_counts_in_the_expense_overview(): void
{
// Dieselbe Regel wie in der EÜR: Ein gelöschter Beleg ist keine Ausgabe. Vor dem Fix zählte er in
// `sumupByInvoiceType()` weiter und verfälschte damit Ausgabenübersicht und Bilanz.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED);
$amount = new CostUnitRepository()->sumupByInvoiceType(
$this->costUnit->fresh(),
InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first()
);
$this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001);
$this->assertEqualsWithDelta(
0.0,
new CostUnitResource($this->costUnit->fresh())->toArray(true)['overAllAmount']['value']->getAmount(),
0.001
);
}
/*
|--------------------------------------------------------------------------
| Helfer
|--------------------------------------------------------------------------
*/
private function statement(): CreateIncomeSurplusStatementResponse
{
// `fresh()`, weil die DB-Vorgaben (Höchstbetrag) im frisch erzeugten Model noch nicht geladen sind
// und EventResource sie als Amount erwartet.
$event = Event::where('cost_unit_id', $this->costUnit->id)->first()->fresh();
$response = new CreateIncomeSurplusStatementCommand(
new CreateIncomeSurplusStatementRequest($event)
)->execute();
$this->assertTrue($response->success, $response->message ?? '');
return $response;
}
/** Der Betrag einer Unterzeile der Einnahmenseite, über alle Kategorien gesucht. */
private function entry(string $name): Amount
{
foreach ($this->statement()->income['categories'] as $category) {
foreach ($category['entries'] as $entry) {
if ($entry['name'] === $name) {
return $entry['amount'];
}
}
}
$this->fail('Einnahmen-Zeile "' . $name . '" fehlt.');
}
/** @return array{name: string, sum: Amount, rows: array<int, array<string, mixed>>} */
private function group(string $name): array
{
foreach ($this->statement()->expenses['groups'] as $group) {
if ($group['name'] === $name) {
return $group;
}
}
$this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.');
}
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,
'support_flat' => 1000.0,
'support_per_person' => 2.0,
'tax_liable' => false,
'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive',
'invoice_key' => 'WM-V-20260701',
]);
}
private function makeParticipant(
Event $event,
float $amount,
float $amountPaid,
bool $unregistered = false
): 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',
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
'arrival_eating' => 1,
'departure_eating' => 1,
'amount' => $amount,
'amount_paid' => $amountPaid,
'unregistered_at' => $unregistered ? '2026-06-12' : null,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]);
}
private function makeInvoice(
string $type,
float $amount,
string $status,
bool $donation = false,
?string $typeOther = null
): 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' => $status,
'type' => $type,
'type_other' => $typeOther,
'donation' => $donation,
'contact_name' => 'Mika Muster',
'comment' => 'Materialkauf',
'amount' => $amount,
]);
}
private function makeAdmin(): User
{
return User::create([
'username' => 'admin-' . uniqid() . '@example.com',
'email' => 'admin-' . uniqid() . '@example.com',
'firstname' => 'Test',
'lastname' => 'Person',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_ADMIN,
'active' => true,
]);
}
}
+31 -3
View File
@@ -40,13 +40,41 @@ class InvoiceTypeSelectionTest extends TestCase
app()->instance('tenant', $tenant);
// Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt.
// Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt. Nur einer trägt
// ein Einkaufsbeispiel -- der andere prüft den Fall einer ungepflegten Spalte.
DB::table('invoice_types')->insert([
['slug' => InvoiceType::INVOICE_TYPE_PROGRAM, 'name' => 'Programmkosten', 'sort_order' => 1, 'selectable' => true],
['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten', 'sort_order' => 3, 'selectable' => true],
[
'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
'name' => 'Programmkosten',
'purchase_example' => 'z. B. Bastelmaterial für den Workshop',
'sort_order' => 1,
'selectable' => true,
],
[
'slug' => InvoiceType::INVOICE_TYPE_OTHER,
'name' => 'Sonstige Kosten',
'purchase_example' => null,
'sort_order' => 3,
'selectable' => true,
],
]);
}
public function test_the_form_receives_the_purchase_example_of_each_type(): void
{
// Das Beispiel im Feld "Was wurde eingekauft" wird in der Tabelle gepflegt, nicht im Formular --
// sonst bräuchte jede Textänderung ein Deployment.
$response = $this->getJson('/api/v1/core/retrieve-invoice-types');
$response->assertOk();
$examples = array_column($response->json('invoiceTypes'), 'purchaseExample', 'slug');
$this->assertSame('z. B. Bastelmaterial für den Workshop', $examples[InvoiceType::INVOICE_TYPE_PROGRAM]);
// Ungepflegt: Das Formular setzt dann seinen eigenen allgemeinen Text ein.
$this->assertNull($examples[InvoiceType::INVOICE_TYPE_OTHER]);
}
public function test_the_refund_type_exists_but_is_not_selectable(): void
{
// `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`.