Dev 4.8.0 #15
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import { ref, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
|
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
|
||||||
import RefundData from "./refund-data.vue";
|
import RefundData from "./refund-data.vue";
|
||||||
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
|
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
|
||||||
@@ -38,6 +38,20 @@ onMounted(async () => {
|
|||||||
Object.assign(invoiceTypeCollection, data);
|
Object.assign(invoiceTypeCollection, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Beispiel im Feld "Was wurde eingekauft" kommt aus `invoice_types.purchase_example` und ist damit
|
||||||
|
* ohne Deployment pflegbar. Ein allgemeines "z. B. Material" hilft beim Ausfüllen nicht weiter, und von
|
||||||
|
* genau diesem Text lebt später die Zweck-Spalte der EüR-Belegliste.
|
||||||
|
*
|
||||||
|
* Ist am Typ nichts gepflegt, greift der allgemeine Text -- ein Feld ohne jede Hilfestellung wäre
|
||||||
|
* schlechter als ein unscharfes Beispiel.
|
||||||
|
*/
|
||||||
|
const purchasePlaceholder = computed(() => {
|
||||||
|
const selected = Object.values(invoiceTypeCollection.invoiceTypes)
|
||||||
|
.find((type) => type.slug === invoiceType.value)
|
||||||
|
|
||||||
|
return selected?.purchaseExample || 'z. B. Material für die Veranstaltung'
|
||||||
|
})
|
||||||
|
|
||||||
function handleFileChange(event) {
|
function handleFileChange(event) {
|
||||||
if (checkFilesize('receipt')) {
|
if (checkFilesize('receipt')) {
|
||||||
@@ -66,28 +80,36 @@ function handleFileChange(event) {
|
|||||||
<InfoIcon :text="'INFO_INVOICE_TYPE_' + availableInvoiceType.slug" /><br />
|
<InfoIcon :text="'INFO_INVOICE_TYPE_' + availableInvoiceType.slug" /><br />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
</fieldset><br /><br />
|
||||||
|
|
||||||
<label for="invoice_type_other">
|
<!--
|
||||||
|
Pflichtangabe zu jeder Rechnung, nicht nur zu "Sonstige Kosten": Erst dieser Text sagt, wofür das
|
||||||
|
Geld ausgegeben wurde, und füllt damit die Zweck-Spalte der EüR-Belegliste.
|
||||||
|
|
||||||
|
Die Schritte bauen aufeinander auf -- ohne Ausgabenart gäbe es kein passendes Beispiel für den
|
||||||
|
Einkauf, und ein Betrag ohne Zweck ließe sich später nicht mehr zuordnen.
|
||||||
|
-->
|
||||||
|
<template v-if="invoiceType !== null">
|
||||||
|
<fieldset>
|
||||||
|
<legend><span style="font-weight: bolder;">Was wurde eingekauft</span></legend>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="width-full"
|
class="width-full"
|
||||||
name="kostengruppe_sonstiges"
|
id="purchase_description"
|
||||||
placeholder="Sonstige"
|
name="purchase_description"
|
||||||
for="invoice_type_other"
|
:placeholder="purchasePlaceholder"
|
||||||
v-model="otherText"
|
v-model="otherText"
|
||||||
@focus="invoiceType = 'other'"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</fieldset><br /><br />
|
||||||
|
</template>
|
||||||
|
|
||||||
</fieldset><br /><br />
|
<fieldset v-if="invoiceType !== null && otherText.trim() !== ''">
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag</span></legend>
|
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag</span></legend>
|
||||||
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
|
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
|
||||||
<info-icon></info-icon><br /><br />
|
<info-icon></info-icon><br /><br />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-if="amount != '' && invoiceType !== null"
|
v-if="amount != ''"
|
||||||
class="mareike-button"
|
class="mareike-button"
|
||||||
onclick="document.getElementById('receipt').click();"
|
onclick="document.getElementById('receipt').click();"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import { ref, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
|
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
|
||||||
import RefundData from "./refund-data.vue";
|
import RefundData from "./refund-data.vue";
|
||||||
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
|
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
|
||||||
@@ -37,6 +37,20 @@ onMounted(async () => {
|
|||||||
Object.assign(invoiceTypeCollection, data);
|
Object.assign(invoiceTypeCollection, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Beispiel im Feld "Was wurde eingekauft" kommt aus `invoice_types.purchase_example` und ist damit
|
||||||
|
* ohne Deployment pflegbar. Ein allgemeines "z. B. Material" hilft beim Ausfüllen nicht weiter, und von
|
||||||
|
* genau diesem Text lebt später die Zweck-Spalte der EüR-Belegliste.
|
||||||
|
*
|
||||||
|
* Ist am Typ nichts gepflegt, greift der allgemeine Text -- ein Feld ohne jede Hilfestellung wäre
|
||||||
|
* schlechter als ein unscharfes Beispiel.
|
||||||
|
*/
|
||||||
|
const purchasePlaceholder = computed(() => {
|
||||||
|
const selected = Object.values(invoiceTypeCollection.invoiceTypes)
|
||||||
|
.find((type) => type.slug === invoiceType.value)
|
||||||
|
|
||||||
|
return selected?.purchaseExample || 'z. B. Material für die Veranstaltung'
|
||||||
|
})
|
||||||
|
|
||||||
function handleFileChange(event) {
|
function handleFileChange(event) {
|
||||||
if (checkFilesize('receipt')) {
|
if (checkFilesize('receipt')) {
|
||||||
@@ -65,28 +79,36 @@ function handleFileChange(event) {
|
|||||||
<InfoIcon :text="'INFO_INVOICE_TYPE_' + availableInvoiceType.slug" /><br />
|
<InfoIcon :text="'INFO_INVOICE_TYPE_' + availableInvoiceType.slug" /><br />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
</fieldset><br /><br />
|
||||||
|
|
||||||
<label for="invoice_type_other">
|
<!--
|
||||||
|
Pflichtangabe zu jeder Abrechnung, nicht nur zu "Sonstige Kosten": Erst dieser Text sagt, wofür
|
||||||
|
das Geld ausgegeben wurde, und füllt damit die Zweck-Spalte der EüR-Belegliste.
|
||||||
|
|
||||||
|
Die Schritte bauen aufeinander auf -- ohne Ausgabenart gäbe es kein passendes Beispiel für den
|
||||||
|
Einkauf, und ein Betrag ohne Zweck ließe sich später nicht mehr zuordnen.
|
||||||
|
-->
|
||||||
|
<template v-if="invoiceType !== null">
|
||||||
|
<fieldset>
|
||||||
|
<legend><span style="font-weight: bolder;">Was wurde eingekauft</span></legend>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="width-full"
|
class="width-full"
|
||||||
name="kostengruppe_sonstiges"
|
id="purchase_description"
|
||||||
placeholder="Sonstige"
|
name="purchase_description"
|
||||||
for="invoice_type_other"
|
:placeholder="purchasePlaceholder"
|
||||||
v-model="otherText"
|
v-model="otherText"
|
||||||
@focus="invoiceType = 'other'"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</fieldset><br /><br />
|
||||||
|
</template>
|
||||||
|
|
||||||
</fieldset><br /><br />
|
<fieldset v-if="invoiceType !== null && otherText.trim() !== ''">
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag</span></legend>
|
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag</span></legend>
|
||||||
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
|
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
|
||||||
<info-icon></info-icon><br /><br />
|
<info-icon></info-icon><br /><br />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-if="amount != '' && invoiceType !== null"
|
v-if="amount != ''"
|
||||||
class="mareike-button"
|
class="mareike-button"
|
||||||
onclick="document.getElementById('receipt').click();"
|
onclick="document.getElementById('receipt').click();"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class InvoiceType extends CommonModel {
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'slug',
|
'slug',
|
||||||
'name',
|
'name',
|
||||||
|
'purchase_example',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'selectable',
|
'selectable',
|
||||||
'counts_as_expense',
|
'counts_as_expense',
|
||||||
@@ -61,10 +62,13 @@ class InvoiceType extends CommonModel {
|
|||||||
* Beitragserstattung mindert bereits die Einnahmenseite, weil der abgemeldete Teili dort herausfällt.
|
* Beitragserstattung mindert bereits die Einnahmenseite, weil der abgemeldete Teili dort herausfällt.
|
||||||
* Als Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
|
* Als Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
|
||||||
*
|
*
|
||||||
|
* Der Name ist zweiter Sortierschlüssel: Die meisten Typen teilen sich `sort_order = 1`, und ohne ihn
|
||||||
|
* stünden die Zeilen der Ausgabenrechnung bei jedem Aufruf in einer anderen Reihenfolge.
|
||||||
|
*
|
||||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||||
*/
|
*/
|
||||||
public static function countingAsExpense(): \Illuminate\Database\Eloquent\Collection
|
public static function countingAsExpense(): \Illuminate\Database\Eloquent\Collection
|
||||||
{
|
{
|
||||||
return self::where('counts_as_expense', true)->orderBy('sort_order')->get();
|
return self::where('counts_as_expense', true)->orderBy('sort_order')->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,29 +121,37 @@ class ProductionDataSeeder {
|
|||||||
|
|
||||||
|
|
||||||
private function installInvoiceMetaData() {
|
private function installInvoiceMetaData() {
|
||||||
|
// `purchase_example` steht im Formular als Beispiel im Feld "Was wurde eingekauft" und ist dort
|
||||||
|
// Pflicht. Der Text hängt an der Ausgabenart, weil ein allgemeines "z. B. Material" beim
|
||||||
|
// Ausfüllen nicht weiterhilft; pflegen lässt er sich anschließend in der Tabelle.
|
||||||
InvoiceType::create([
|
InvoiceType::create([
|
||||||
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
|
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
|
||||||
'name' => 'Reisekosten'
|
'name' => 'Reisekosten',
|
||||||
|
'purchase_example' => 'z. B. Bahnfahrt Halle–Leipzig',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InvoiceType::create([
|
InvoiceType::create([
|
||||||
'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
|
'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
|
||||||
'name' => 'Programmkosten'
|
'name' => 'Programmkosten',
|
||||||
|
'purchase_example' => 'z. B. Bastelmaterial für den Workshop',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InvoiceType::create([
|
InvoiceType::create([
|
||||||
'slug' => InvoiceType::INVOICE_TYPE_ACCOMMODATION,
|
'slug' => InvoiceType::INVOICE_TYPE_ACCOMMODATION,
|
||||||
'name' => 'Unterkunftskosten'
|
'name' => 'Unterkunftskosten',
|
||||||
|
'purchase_example' => 'z. B. zwei Nächte Jugendherberge',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InvoiceType::create([
|
InvoiceType::create([
|
||||||
'slug' => InvoiceType::INVOICE_TYPE_CATERING,
|
'slug' => InvoiceType::INVOICE_TYPE_CATERING,
|
||||||
'name' => 'Verpflegungskosten',
|
'name' => 'Verpflegungskosten',
|
||||||
|
'purchase_example' => 'z. B. Wocheneinkauf für das Frühstück',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InvoiceType::create([
|
InvoiceType::create([
|
||||||
'slug' => InvoiceType::INVOICE_TYPE_OTHER,
|
'slug' => InvoiceType::INVOICE_TYPE_OTHER,
|
||||||
'name' => 'Sonstige Kosten'
|
'name' => 'Sonstige Kosten',
|
||||||
|
'purchase_example' => 'z. B. Erste-Hilfe-Set',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ class GlobalDataProvider {
|
|||||||
|
|
||||||
$invoiceTypes[] = [
|
$invoiceTypes[] = [
|
||||||
'slug' => $invoiceType->slug,
|
'slug' => $invoiceType->slug,
|
||||||
'name' => $invoiceType->name
|
'name' => $invoiceType->name,
|
||||||
|
// Beispieltext für "Was wurde eingekauft". Ist er nicht gepflegt, setzt das Formular
|
||||||
|
// seinen eigenen allgemeinen Text ein.
|
||||||
|
'purchaseExample' => $invoiceType->purchase_example,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Enumerations\InvoiceStatus;
|
|||||||
use App\Enumerations\InvoiceType;
|
use App\Enumerations\InvoiceType;
|
||||||
use App\Enumerations\UserRole;
|
use App\Enumerations\UserRole;
|
||||||
use App\Models\CostUnit;
|
use App\Models\CostUnit;
|
||||||
|
use App\Models\Invoice;
|
||||||
use App\Providers\AuthCheckProvider;
|
use App\Providers\AuthCheckProvider;
|
||||||
use App\Resources\CostUnitResource;
|
use App\Resources\CostUnitResource;
|
||||||
use App\ValueObjects\Amount;
|
use App\ValueObjects\Amount;
|
||||||
@@ -153,8 +154,7 @@ class CostUnitRepository {
|
|||||||
|
|
||||||
foreach ($costUnit->invoices()->get() as $invoice) {
|
foreach ($costUnit->invoices()->get() as $invoice) {
|
||||||
if (
|
if (
|
||||||
$invoice->status === InvoiceStatus::INVOICE_STATUS_DENIED ||
|
!self::countsAsExpense($invoice) ||
|
||||||
$invoice->donation ||
|
|
||||||
$invoice->type !== $invoiceType->slug
|
$invoice->type !== $invoiceType->slug
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
@@ -165,6 +165,52 @@ class CostUnitRepository {
|
|||||||
return $amount;
|
return $amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Ausgaben einer Kostenstelle, nach Ausgabentyp gruppiert -- Summe und die Belege dahinter.
|
||||||
|
*
|
||||||
|
* Jeder zählende Typ steht im Ergebnis, auch ohne Beleg: Die Einnahmen-Überschuss-Rechnung zeigt
|
||||||
|
* damit immer dieselbe Gliederung, und eine fehlende Zeile lässt sich nicht mit einer vergessenen
|
||||||
|
* verwechseln.
|
||||||
|
*
|
||||||
|
* @return array<string, array{type: InvoiceType, invoices: array<int, Invoice>, sum: Amount}>
|
||||||
|
*/
|
||||||
|
public function groupExpensesByType(CostUnit $costUnit) : array {
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
|
||||||
|
$groups[$invoiceType->slug] = [
|
||||||
|
'type' => $invoiceType,
|
||||||
|
'invoices' => [],
|
||||||
|
'sum' => new Amount(0, 'Euro'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($costUnit->invoices()->orderBy('invoice_number')->get() as $invoice) {
|
||||||
|
if (!self::countsAsExpense($invoice) || !isset($groups[$invoice->type])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups[$invoice->type]['invoices'][] = $invoice;
|
||||||
|
$groups[$invoice->type]['sum']->addAmount(Amount::fromString($invoice->amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private static function countsAsExpense(Invoice $invoice) : bool {
|
||||||
|
return !in_array($invoice->status, [
|
||||||
|
InvoiceStatus::INVOICE_STATUS_DENIED,
|
||||||
|
InvoiceStatus::INVOICE_STATUS_DELETED,
|
||||||
|
], true)
|
||||||
|
&& !$invoice->donation;
|
||||||
|
}
|
||||||
|
|
||||||
public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount {
|
public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount {
|
||||||
$amount = new Amount(0, 'Euro');
|
$amount = new Amount(0, 'Euro');
|
||||||
foreach ($costUnit->estimates()->get() as $estimate) {
|
foreach ($costUnit->estimates()->get() as $estimate) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Beispiel, das im Feld „Was wurde eingekauft" steht, solange es leer ist.
|
||||||
|
*
|
||||||
|
* Die Angabe ist seit der EüR Pflicht zu jeder Abrechnung, und von ihr lebt dort die Zweck-Spalte. Ein
|
||||||
|
* allgemeines „z. B. Material" hilft beim Ausfüllen nicht weiter -- was ein brauchbarer Zweck ist, hängt
|
||||||
|
* an der Ausgabenart. Der Text gehört deshalb an den Typ und nicht in den Quelltext des Formulars: So
|
||||||
|
* lässt er sich ohne Deployment nachschärfen, und ein später ergänzter Typ bringt sein Beispiel mit.
|
||||||
|
*
|
||||||
|
* Bleibt die Spalte leer, zeigt das Formular seinen eigenen allgemeinen Text -- ein Typ ohne Beispiel
|
||||||
|
* soll das Feld nicht ohne Hilfestellung lassen.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
/**
|
||||||
|
* Die Ausgangstexte. Bewusst konkret: Sie sollen zeigen, wie genau eine Angabe gemeint ist.
|
||||||
|
*/
|
||||||
|
private const array EXAMPLES = [
|
||||||
|
'travelling' => 'z. B. Bahnfahrt Halle–Leipzig',
|
||||||
|
'program' => 'z. B. Bastelmaterial für den Workshop',
|
||||||
|
'accommodation' => 'z. B. zwei Nächte Jugendherberge',
|
||||||
|
'catering' => 'z. B. Wocheneinkauf für das Frühstück',
|
||||||
|
'logistic' => 'z. B. Miete für den Transporter',
|
||||||
|
'technical' => 'z. B. Verlängerungskabel und Mehrfachstecker',
|
||||||
|
'management' => 'z. B. Porto und Kopien',
|
||||||
|
'other' => 'z. B. Erste-Hilfe-Set',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('invoice_types', function (Blueprint $table) {
|
||||||
|
$table->string('purchase_example')->nullable()->after('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (self::EXAMPLES as $slug => $example) {
|
||||||
|
DB::table('invoice_types')
|
||||||
|
->where('slug', $slug)
|
||||||
|
->update(['purchase_example' => $example]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('invoice_types', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('purchase_example');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
margin: 15mm 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: DejaVu Sans, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 15pt;
|
||||||
|
margin: 0 0 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 11pt;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin: 8mm 0 2mm;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subline {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Rechnung selbst: keine Rahmen, nur Linien vor den Summen -- so liest sich eine Aufstellung
|
||||||
|
schneller als eine Gittertabelle. */
|
||||||
|
table.statement {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.statement td {
|
||||||
|
padding: 1.2mm 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.statement td.value {
|
||||||
|
text-align: right;
|
||||||
|
width: 30mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.category td {
|
||||||
|
font-weight: bold;
|
||||||
|
padding-top: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.entry td.label {
|
||||||
|
padding-left: 8mm;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.entry td.value {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.sum td {
|
||||||
|
font-weight: bold;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
padding-top: 1.5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.result td {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12pt;
|
||||||
|
padding-top: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive { color: #4caf50; }
|
||||||
|
.negative { color: #f44336; }
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
margin-top: 10mm;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Anlage bleibt eine klassische Belegtabelle mit Rahmen. */
|
||||||
|
table.receipts {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.receipts th,
|
||||||
|
table.receipts td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 8pt;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.receipts th {
|
||||||
|
background: #f2f2f2;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.receipts td.value,
|
||||||
|
table.receipts th.value {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 22mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.receipts tr.group-sum td {
|
||||||
|
font-weight: bold;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 10pt;
|
||||||
|
margin: 5mm 0 1.5mm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Einnahmen-Überschuss-Rechnung</h1>
|
||||||
|
<div class="subline">
|
||||||
|
{{ $event->name }}
|
||||||
|
· {{ $event->start_date->format('d.m.Y') }}–{{ $event->end_date->format('d.m.Y') }}
|
||||||
|
@if($event->location)· {{ $event->location }}@endif
|
||||||
|
· Stand: {{ $createdAt }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Einnahmen</h2>
|
||||||
|
<table class="statement">
|
||||||
|
@foreach($income['categories'] as $category)
|
||||||
|
<tr class="category">
|
||||||
|
<td>{{ $category['name'] }}</td>
|
||||||
|
<td class="value">{{ $money($category['total']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@foreach($category['entries'] as $entry)
|
||||||
|
<tr class="entry">
|
||||||
|
<td class="label">{{ $entry['name'] }}</td>
|
||||||
|
<td class="value">{{ $money($entry['amount']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@endforeach
|
||||||
|
<tr class="sum">
|
||||||
|
<td>Summe Einnahmen</td>
|
||||||
|
<td class="value">{{ $money($income['total']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Ausgaben</h2>
|
||||||
|
<table class="statement">
|
||||||
|
@foreach($expenses['groups'] as $group)
|
||||||
|
<tr class="entry">
|
||||||
|
<td class="label">{{ $group['name'] }}</td>
|
||||||
|
<td class="value">{{ $money($group['sum']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
<tr class="sum">
|
||||||
|
<td>Summe Ausgaben</td>
|
||||||
|
<td class="value">{{ $money($expenses['total']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="statement">
|
||||||
|
<tr class="result">
|
||||||
|
<td class="{{ $result->getAmount() >= 0 ? 'positive' : 'negative' }}">
|
||||||
|
{{ $result->getAmount() >= 0 ? 'Überschuss' : 'Fehlbetrag' }}
|
||||||
|
</td>
|
||||||
|
<td class="value {{ $result->getAmount() >= 0 ? 'positive' : 'negative' }}">
|
||||||
|
{{ $money($result) }} €
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
@if($event->tax_liable)
|
||||||
|
<br />Alle Beträge sind Bruttobeträge inklusive {{ $event->vat_rate }} % Umsatzsteuer.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$documentedGroups = array_filter($expenses['groups'], fn ($group) => count($group['rows']) > 0);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if(count($documentedGroups) > 0)
|
||||||
|
<div class="page-break"></div>
|
||||||
|
|
||||||
|
<h1>Anlage: Belege</h1>
|
||||||
|
<div class="subline">{{ $event->name }} · 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']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
<tr class="group-sum">
|
||||||
|
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
|
||||||
|
<td class="value">{{ $money($group['sum']) }} €</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,13 +40,41 @@ class InvoiceTypeSelectionTest extends TestCase
|
|||||||
|
|
||||||
app()->instance('tenant', $tenant);
|
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([
|
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
|
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`.
|
// `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`.
|
||||||
|
|||||||
Reference in New Issue
Block a user