Verbesserte EÜR-Abrechnung

This commit is contained in:
2026-10-01 13:40:51 +02:00
parent a43fd9cedf
commit bcebe2ebed
7 changed files with 252 additions and 47 deletions
@@ -5,6 +5,7 @@ namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
use App\Enumerations\ParticipationType;
use App\Models\CostUnit;
use App\Models\Event;
use App\Models\Invoice;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\Repositories\CostUnitRepository;
use App\ValueObjects\Amount;
@@ -52,8 +53,9 @@ class CreateIncomeSurplusStatementCommand
$eventData = $this->event->toResource()->toArray(new Request());
$income = $this->buildIncome($eventData, $otherIncome);
$expenses = $this->buildExpenses($costUnit);
$donations = $this->buildExpenses($costUnit, donationsOnly: true);
$income = $this->buildIncome($eventData, $otherIncome, $donations['total']);
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
@@ -62,6 +64,7 @@ class CreateIncomeSurplusStatementCommand
'createdAt' => new \DateTime()->format('d.m.Y'),
'income' => $income,
'expenses' => $expenses,
'donations' => $donations,
'result' => $result,
'money' => self::money(...),
])->render();
@@ -70,6 +73,7 @@ class CreateIncomeSurplusStatementCommand
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
$response->income = $income;
$response->expenses = $expenses;
$response->donations = $donations;
$response->result = $result;
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
@@ -87,7 +91,7 @@ class CreateIncomeSurplusStatementCommand
*
* @return array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}
*/
private function buildIncome(array $eventData, float $otherIncome): array
private function buildIncome(array $eventData, float $otherIncome, Amount $donations): array
{
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
// hereingekommen ist, nicht von wem.
@@ -118,6 +122,8 @@ class CreateIncomeSurplusStatementCommand
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
],
// Gespendete Auslagen: als Ausgabe ihres Typs gebucht, hier die Gegenbuchung.
['name' => 'Spenden', 'amount' => $donations],
];
$categories = [
@@ -134,14 +140,17 @@ class CreateIncomeSurplusStatementCommand
/**
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
*
* Mit `$donationsOnly` nur die gespendeten Auslagen in derselben Gliederung -- für die Anlage „Spenden"
* und die Summe unter den Förderungen.
*
* @return array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array{number: string, date: string, purpose: string, amount: Amount}>}>, total: Amount}
*/
private function buildExpenses(CostUnit $costUnit): array
private function buildExpenses(CostUnit $costUnit, bool $donationsOnly = false): array
{
$groups = [];
$total = new Amount(0, 'Euro');
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
foreach ($this->costUnits->groupExpensesByType($costUnit, $donationsOnly) as $group) {
$rows = [];
foreach ($group['invoices'] as $invoice) {
@@ -150,7 +159,7 @@ class CreateIncomeSurplusStatementCommand
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
// Ohne die Anmerkung: dort steht, was die Kassenwart*in beim Korrigieren notiert hat,
// und das gehört auf den Beleg, nicht in den Zweck.
'purpose' => $invoice->purposeText(),
'purpose' => self::purpose($invoice),
'amount' => Amount::fromString($invoice->amount),
];
}
@@ -167,6 +176,25 @@ class CreateIncomeSurplusStatementCommand
return ['groups' => $groups, 'total' => $total];
}
/**
* Der Zweck eines Belegs für die Anlage.
*
* Erfasster oder abgeleiteter Zahlungsgrund wie in der Beleg-Übersicht. Nur wenn es keinen gibt --
* ältere Abrechnungen ohne jede Zweckangabe --, steht dort ersatzweise, wem was erstattet wurde. Bewusst
* hier und nicht in {@see Invoice::purposeText()}: Dort würde der Ersatztext als erfasster Zahlungsgrund
* erscheinen und beim Korrigieren als Änderung protokolliert.
*/
private static function purpose(Invoice $invoice): string
{
$purpose = $invoice->purposeText();
if (trim($purpose) !== '') {
return $purpose;
}
return trim('Auslagenerstattung ' . $invoice->contact_name);
}
/**
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
*
@@ -29,5 +29,12 @@ class CreateIncomeSurplusStatementResponse
*/
public array $expenses = [];
/**
* Die gespendeten Auslagen, gegliedert wie die Ausgaben -- die Gegenbuchung zur Zeile „Spenden".
*
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
*/
public array $donations = [];
public ?Amount $result = null;
}
+23 -7
View File
@@ -172,9 +172,13 @@ class CostUnitRepository {
* damit immer dieselbe Gliederung, und eine fehlende Zeile lässt sich nicht mit einer vergessenen
* verwechseln.
*
* Gespendete Auslagen zählen hier mit: Für die Einnahmen-Überschuss-Rechnung ist die Spende eine Ausgabe
* ihres Typs und zugleich eine Einnahme. Mit `$donationsOnly` liefert die Methode genau diesen Spendenanteil
* in derselben Gliederung -- die Gegenbuchungen lassen sich so ihrer Ausgabenkategorie zuordnen.
*
* @return array<string, array{type: InvoiceType, invoices: array<int, Invoice>, sum: Amount}>
*/
public function groupExpensesByType(CostUnit $costUnit) : array {
public function groupExpensesByType(CostUnit $costUnit, bool $donationsOnly = false) : array {
$groups = [];
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
@@ -186,7 +190,11 @@ class CostUnitRepository {
}
foreach ($costUnit->invoices()->orderBy('invoice_number')->get() as $invoice) {
if (!self::countsAsExpense($invoice) || !isset($groups[$invoice->type])) {
if (
!self::isBooked($invoice) ||
!isset($groups[$invoice->type]) ||
($donationsOnly && !$invoice->donation)
) {
continue;
}
@@ -201,14 +209,22 @@ class CostUnitRepository {
* Ob ein Beleg als Ausgabe zählt.
*
* Abgelehnt und gelöscht sind keine Ausgabe, weil kein Geld fließt. Dasselbe gilt für eine gespendete
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus.
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus. Die Einnahmen-Überschuss-
* Rechnung sieht das anders und bucht die Spende als Ausgabe und Einnahme, siehe
* {@see self::groupExpensesByType()}.
*/
private static function countsAsExpense(Invoice $invoice) : bool {
return self::isBooked($invoice) && !$invoice->donation;
}
/**
* Ob ein Beleg überhaupt gebucht ist -- abgelehnt und gelöscht sind es nicht.
*/
private static function isBooked(Invoice $invoice) : bool {
return !in_array($invoice->status, [
InvoiceStatus::INVOICE_STATUS_DENIED,
InvoiceStatus::INVOICE_STATUS_DELETED,
], true)
&& !$invoice->donation;
InvoiceStatus::INVOICE_STATUS_DENIED,
InvoiceStatus::INVOICE_STATUS_DELETED,
], true);
}
public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount {
@@ -186,8 +186,8 @@
<div class="footnote">
Berücksichtigt sind nur tatsächlich vereinnahmte Beiträge und erfasste Belege. Nicht enthalten sind
offene Beiträge, Budget- und Schätzwerte, erstattete Beiträge sowie Auslagen, auf deren Auszahlung
verzichtet wurde.
offene Beiträge, Budget- und Schätzwerte sowie erstattete Beiträge. Gespendete Auslagen sind bei den
Ausgaben enthalten und zugleich als Spenden unter den Förderungen ausgewiesen.
@if($event->tax_liable)
<br />Alle Beträge sind Bruttobeträge inklusive {{ $event->vat_rate }} % Umsatzsteuer.
@endif
@@ -203,33 +203,27 @@
<h1>Anlage: Belege</h1>
<div class="subline">{{ $event->name }} &middot; Stand: {{ $createdAt }}</div>
@foreach($documentedGroups as $group)
<h3>{{ $group['name'] }}</h3>
<table class="receipts">
<thead>
<tr>
<th style="width: 22mm;">Beleg-Nr.</th>
<th style="width: 18mm;">Datum</th>
<th>Zweck</th>
<th class="value">Betrag</th>
</tr>
</thead>
<tbody>
@foreach($group['rows'] as $row)
<tr>
<td>{{ $row['number'] }}</td>
<td>{{ $row['date'] }}</td>
<td>{{ $row['purpose'] }}</td>
<td class="value">{{ $money($row['amount']) }} &euro;</td>
</tr>
@endforeach
<tr class="group-sum">
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
<td class="value">{{ $money($group['sum']) }} &euro;</td>
</tr>
</tbody>
</table>
@endforeach
@include('pdfs.partials.receipt-groups', ['groups' => $documentedGroups])
@endif
@php
$donatedGroups = array_filter($donations['groups'], fn ($group) => count($group['rows']) > 0);
@endphp
@if(count($donatedGroups) > 0)
<div class="page-break"></div>
<h1>Anlage: Spenden</h1>
<div class="subline">{{ $event->name }} &middot; Stand: {{ $createdAt }}</div>
@include('pdfs.partials.receipt-groups', ['groups' => $donatedGroups])
<table class="statement">
<tr class="sum">
<td>Summe Spenden</td>
<td class="value">{{ $money($donations['total']) }} &euro;</td>
</tr>
</table>
@endif
</body>
@@ -0,0 +1,28 @@
{{-- Belege je Ausgabentyp mit Zwischensumme; nur Gruppen mit Belegen übergeben. --}}
@foreach($groups as $group)
<h3>{{ $group['name'] }}</h3>
<table class="receipts">
<thead>
<tr>
<th style="width: 22mm;">Beleg-Nr.</th>
<th style="width: 18mm;">Datum</th>
<th>Zweck</th>
<th class="value">Betrag</th>
</tr>
</thead>
<tbody>
@foreach($group['rows'] as $row)
<tr>
<td>{{ $row['number'] }}</td>
<td>{{ $row['date'] }}</td>
<td>{{ $row['purpose'] }}</td>
<td class="value">{{ $money($row['amount']) }} &euro;</td>
</tr>
@endforeach
<tr class="group-sum">
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
<td class="value">{{ $money($group['sum']) }} &euro;</td>
</tr>
</tbody>
</table>
@endforeach
@@ -202,11 +202,95 @@ class EventIncomeSurplusStatementTest extends TestCase
// 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);
// Eine Spende ist Ausgabe ihres Typs (und zugleich Einnahme unter den Förderungen):
$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(927.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);
$this->assertEqualsWithDelta(957.0, $this->statement()->expenses['total']->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Spenden
|--------------------------------------------------------------------------
*/
public function test_without_donations_the_donation_line_is_zero(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertEqualsWithDelta(0.0, $this->entry('Spenden')->getAmount(), 0.001);
$this->assertEqualsWithDelta(0.0, $this->statement()->donations['total']->getAmount(), 0.001);
}
public function test_a_donation_is_an_expense_of_its_type_and_funding_income(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$resultWithoutDonation = $this->statement()->result->getAmount();
$donated = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 40.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
$statement = $this->statement();
$program = $this->group('Programmkosten');
$this->assertEqualsWithDelta(140.0, $program['sum']->getAmount(), 0.001);
$this->assertContains($donated->invoice_number, array_column($program['rows'], 'number'));
// Gegenbuchung unter den Förderungen (ohne Anmeldung keine Fördermittel, also nur die Spende).
$this->assertSame('Förderungen', $statement->income['categories'][1]['name']);
$this->assertEqualsWithDelta(40.0, $this->entry('Spenden')->getAmount(), 0.001);
$this->assertEqualsWithDelta(40.0, $statement->income['categories'][1]['total']->getAmount(), 0.001);
// Ausgabe und Einnahme heben sich auf.
$this->assertEqualsWithDelta($resultWithoutDonation, $statement->result->getAmount(), 0.001);
}
public function test_donations_are_grouped_like_expenses(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$programDonation = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 25.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 15.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 60.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
$donations = $this->statement()->donations;
// Dieselbe Gliederung wie die Ausgaben, auch Typen ohne Spende stehen darin.
$this->assertSame(
array_column($this->statement()->expenses['groups'], 'name'),
array_column($donations['groups'], 'name')
);
$this->assertEqualsWithDelta(40.0, $this->donationGroup('Programmkosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(60.0, $this->donationGroup('Verpflegungskosten')['sum']->getAmount(), 0.001);
$this->assertEqualsWithDelta(100.0, $donations['total']->getAmount(), 0.001);
$this->assertEqualsWithDelta(100.0, $this->entry('Spenden')->getAmount(), 0.001);
// Derselbe Beleg steht als Ausgabe und als Spende.
$this->assertContains($programDonation->invoice_number, array_column($this->group('Programmkosten')['rows'], 'number'));
$this->assertContains($programDonation->invoice_number, array_column($this->donationGroup('Programmkosten')['rows'], 'number'));
// Nicht gespendete Belege stehen nicht in der Spenden-Anlage.
$this->assertCount(2, $this->donationGroup('Programmkosten')['rows']);
}
public function test_donations_list_only_booked_donations(): void
{
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 20.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED, donation: true);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
$donations = $this->statement()->donations;
$this->assertEqualsWithDelta(20.0, $donations['total']->getAmount(), 0.001);
$this->assertCount(1, $this->donationGroup('Programmkosten')['rows']);
$this->assertNotContains('Beitragserstattung', array_column($donations['groups'], 'name'));
}
public function test_a_refund_is_no_expense(): void
@@ -311,12 +395,34 @@ class EventIncomeSurplusStatementTest extends TestCase
public function test_the_comment_is_no_longer_part_of_the_purpose(): void
{
// Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem
// Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben
// hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren.
// Beleg-PDF und hat im Zweck nichts zu suchen.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']);
$this->assertStringNotContainsString('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
}
public function test_an_empty_purpose_falls_back_to_the_refund_of_the_contact(): void
{
// Ältere Abrechnungen ohne jede Zweckangabe: ersatzweise, wem erstattet wurde -- als Ausgabe wie als Spende.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 30.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$this->makeInvoice(
InvoiceType::INVOICE_TYPE_PROGRAM,
50.0,
InvoiceStatus::INVOICE_STATUS_NEW,
purpose: 'Material für den Bastelnachmittag'
);
$this->assertSame(
['Auslagenerstattung Mika Muster', 'Auslagenerstattung Mika Muster', 'Material für den Bastelnachmittag'],
array_column($this->group('Programmkosten')['rows'], 'purpose')
);
$this->assertSame(
['Auslagenerstattung Mika Muster'],
array_column($this->donationGroup('Programmkosten')['rows'], 'purpose')
);
}
/*
@@ -388,6 +494,20 @@ class EventIncomeSurplusStatementTest extends TestCase
);
}
public function test_a_donation_still_does_not_count_in_the_expense_overview(): void
{
// Die Bildschirm-Übersicht kennt keine Spenden-Einnahme -- dort bleibt die Spende außen vor.
$this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
$amount = new CostUnitRepository()->sumupByInvoiceType(
$this->costUnit->fresh(),
InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first()
);
$this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Helfer
@@ -435,6 +555,18 @@ class EventIncomeSurplusStatementTest extends TestCase
$this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.');
}
/** @return array{name: string, sum: Amount, rows: array<int, array<string, mixed>>} */
private function donationGroup(string $name): array
{
foreach ($this->statement()->donations['groups'] as $group) {
if ($group['name'] === $name) {
return $group;
}
}
$this->fail('Spenden-Gruppe "' . $name . '" fehlt.');
}
private function makeEvent(): Event
{
$fee = EventParticipationFee::create([
+1 -1
View File
@@ -1 +1 @@
4.10.1
4.10.2