7 Commits
95 changed files with 35098 additions and 191 deletions
+29010
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ class UserDetailGetController extends CommonController
return response()->json([ return response()->json([
'user' => $userData, 'user' => $userData,
'isOwnUser' => auth()->id() === $user->id, 'isOwnUser' => currentUser()?->id === $user->id,
'isLvTenant' => $this->tenant->slug === 'lv', 'isLvTenant' => $this->tenant->slug === 'lv',
'userRoles' => UserRole::all()->map(fn($role) => ['slug' => $role->slug, 'name' => $role->name]), 'userRoles' => UserRole::all()->map(fn($role) => ['slug' => $role->slug, 'name' => $role->name]),
'localGroups' => $this->adminTenants->getActiveLocalGroups()->map(fn($t) => ['slug' => $t->slug, 'name' => $t->name]), 'localGroups' => $this->adminTenants->getActiveLocalGroups()->map(fn($t) => ['slug' => $t->slug, 'name' => $t->name]),
@@ -16,7 +16,7 @@ class UserToggleActiveController extends CommonController
$action = new ToggleUserActiveAction(new ToggleUserActiveRequest( $action = new ToggleUserActiveAction(new ToggleUserActiveRequest(
user: $user, user: $user,
currentUserId: auth()->id(), currentUserId: currentUser()?->id,
)); ));
$response = $action->execute(); $response = $action->execute();
@@ -17,7 +17,7 @@ class UserUpdateController extends CommonController
$action = new UpdateUserAction(new UpdateUserRequest( $action = new UpdateUserAction(new UpdateUserRequest(
user: $user, user: $user,
data: $request->all(), data: $request->all(),
isOwnUser: auth()->id() === $user->id, isOwnUser: currentUser()?->id === $user->id,
isLvTenant: $this->tenant->slug === 'lv', isLvTenant: $this->tenant->slug === 'lv',
)); ));
@@ -25,7 +25,7 @@ class CreateEstimateAction {
if ($this->request->estimateId === 0) { if ($this->request->estimateId === 0) {
$estimate = CostUnitEstimate::create(array_merge([ $estimate = CostUnitEstimate::create(array_merge([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'cost_unit_id' => $this->request->costUnit->id, 'cost_unit_id' => $this->request->costUnit->id,
'type' => $this->request->estimateType, 'type' => $this->request->estimateType,
'description' => $this->request->description, 'description' => $this->request->description,
@@ -33,7 +33,7 @@ class CreateEstimateAction {
} else { } else {
$estimate = CostUnitEstimate::find($this->request->estimateId); $estimate = CostUnitEstimate::find($this->request->estimateId);
$estimate->update(array_merge([ $estimate->update(array_merge([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'cost_unit_id' => $this->request->costUnit->id, 'cost_unit_id' => $this->request->costUnit->id,
'type' => $this->request->estimateType, 'type' => $this->request->estimateType,
'description' => $this->request->description, 'description' => $this->request->description,
@@ -15,7 +15,7 @@ class CreateCostUnitCommand {
$response = new CreateCostUnitResponse(); $response = new CreateCostUnitResponse();
$costUnit = CostUnit::create([ $costUnit = CostUnit::create([
'name' => $this->request->name, 'name' => $this->request->name,
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->type, 'type' => $this->request->type,
'billing_deadline' => $this->request->billingDeadline, 'billing_deadline' => $this->request->billingDeadline,
'distance_allowance' => $this->request->distanceAllowance->getAmount(), 'distance_allowance' => $this->request->distanceAllowance->getAmount(),
@@ -13,7 +13,7 @@ class PersonalDataController extends CommonController
return redirect()->intended('/login'); return redirect()->intended('/login');
} }
$user = auth()->user(); $user = currentUser();
$data = $this->users->getPersonalData($user); $data = $this->users->getPersonalData($user);
$inertiaProvider = new InertiaProvider('Dashboard/PersonalData', [ $inertiaProvider = new InertiaProvider('Dashboard/PersonalData', [
@@ -12,7 +12,7 @@ class StorePersonalDataController extends CommonController
{ {
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
$user = auth()->user(); $user = currentUser();
$actionRequest = new UpdatePersonalDataRequest( $actionRequest = new UpdatePersonalDataRequest(
user: $user, user: $user,
@@ -28,7 +28,7 @@ class CreateEventCommand {
} }
$tenant = app('tenant'); $tenant = currentTenant();
// Anlage als Ganzes: der Event-Teil der Rechnungsnummer wird unter der Sperre vergeben und muss // Anlage als Ganzes: der Event-Teil der Rechnungsnummer wird unter der Sperre vergeben und muss
// im selben Zug geschrieben werden, sonst könnten zwei gleichzeitige Anlagen denselben erhalten. // im selben Zug geschrieben werden, sonst könnten zwei gleichzeitige Anlagen denselben erhalten.
@@ -91,12 +91,12 @@ class CreateEventCommand {
]); ]);
} }
if (app('tenant')->slug === 'lv') { if (currentTenant()->slug === 'lv') {
foreach(Tenant::where(['is_active_local_group' => true])->get() as $tenant) { foreach(Tenant::where(['is_active_local_group' => true])->get() as $tenant) {
EventLocalGroups::create(['event_id' => $event->id, 'local_group_id' => $tenant->id]); EventLocalGroups::create(['event_id' => $event->id, 'local_group_id' => $tenant->id]);
} }
} else { } else {
EventLocalGroups::create(['event_id' => $event->id, 'local_group_id' => app('tenant')->id]); EventLocalGroups::create(['event_id' => $event->id, 'local_group_id' => currentTenant()->id]);
} }
@@ -0,0 +1,224 @@
<?php
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
use App\Enumerations\ParticipationType;
use App\Models\CostUnit;
use App\Models\Event;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\Repositories\CostUnitRepository;
use App\ValueObjects\Amount;
use Illuminate\Http\Request;
/**
* Erzeugt die Einnahmen-Überschuss-Rechnung einer Veranstaltung als PDF.
*
* Gezeigt wird ausschließlich Geld, das geflossen ist: gezahlte Beiträge, weitere Einnahmen, was von
* Abmeldungen einbehalten wurde, Fördermittel -- und auf der anderen Seite die erfassten Belege. Was nur
* erwartet (offene Beiträge) oder geplant (Budgetwerte) ist, gehört in eine Einnahmen-Überschuss-Rechnung
* nicht hinein.
*
* Es wird nichts gespeichert: Alle Zahlen leiten sich aus dem aktuellen Stand ab, ein erneuter Abruf
* liefert den dann gültigen Stand.
*/
class CreateIncomeSurplusStatementCommand
{
private Event $event;
private CostUnitRepository $costUnits;
public function __construct(private readonly CreateIncomeSurplusStatementRequest $request)
{
$this->event = $request->event;
$this->costUnits = new CostUnitRepository();
}
public function execute(): CreateIncomeSurplusStatementResponse
{
$response = new CreateIncomeSurplusStatementResponse();
$costUnit = $this->event->costUnit()->first();
if (!$costUnit instanceof CostUnit) {
$response->message = 'Der Veranstaltung ist keine Kostenstelle zugeordnet.';
return $response;
}
// Der Pauschalbetrag wird vor dem Resource-Aufruf gelesen: `EventResource::calculateSupportPerPerson()`
// multipliziert das Amount-Objekt von `support_per_person` in place. Auf `support_flat` wirkt das
// zwar nicht, aber der gesamte Zugriff auf Beträge des Models ist danach nicht mehr vertrauenswürdig.
$otherIncome = $this->event->support_flat->getAmount();
$eventData = $this->event->toResource()->toArray(new Request());
$income = $this->buildIncome($eventData, $otherIncome);
$expenses = $this->buildExpenses($costUnit);
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
$html = view('pdfs.income-surplus-statement', [
'event' => $this->event,
'createdAt' => new \DateTime()->format('d.m.Y'),
'income' => $income,
'expenses' => $expenses,
'result' => $result,
'money' => self::money(...),
])->render();
$response->success = true;
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
$response->income = $income;
$response->expenses = $expenses;
$response->result = $result;
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
return $response;
}
/**
* Die Einnahmenseite in zwei Ober-Kategorien.
*
* Alle Zahlen stammen aus {@see \App\Resources\EventResource} -- derselben Quelle wie die
* Veranstaltungsübersicht am Bildschirm. Eine eigene Rechnung daneben würde über kurz oder lang von
* der Übersicht abweichen, und dann glaubt niemand mehr einer der beiden Zahlen.
*
* @param array<string, mixed> $eventData
*
* @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
{
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
// hereingekommen ist, nicht von wem.
$participationFees = new Amount(0, 'Euro');
foreach ([
ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
ParticipationType::PARTICIPATION_TYPE_TEAM,
ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
ParticipationType::PARTICIPATION_TYPE_OTHER,
] as $participationType) {
$participationFees->addAmount(
new Amount((float) $eventData['participants'][$participationType]['amount']['paid']['value'], 'Euro')
);
}
$ownFunds = [
['name' => 'Teilnahmebeiträge', 'amount' => $participationFees],
['name' => 'Weitere Einnahmen', 'amount' => new Amount($otherIncome, 'Euro')],
[
'name' => 'Einbehaltene Einnahmen aus Abmeldungen',
'amount' => new Amount((float) $eventData['retainedFromUnregistered']['value'], 'Euro'),
],
];
$supportRate = new Amount((float) $eventData['supportPersonValue'], 'Euro');
$funding = [
[
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
],
];
$categories = [
['name' => 'Eigenmittel', 'entries' => $ownFunds, 'total' => self::sum($ownFunds)],
['name' => 'Förderungen', 'entries' => $funding, 'total' => self::sum($funding)],
];
return [
'categories' => $categories,
'total' => self::sum($categories),
];
}
/**
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
*
* @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
{
$groups = [];
$total = new Amount(0, 'Euro');
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
$rows = [];
foreach ($group['invoices'] as $invoice) {
$rows[] = [
'number' => (string) $invoice->invoice_number,
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
'amount' => Amount::fromString($invoice->amount),
];
}
$groups[] = [
'name' => $group['type']->name,
'sum' => $group['sum'],
'rows' => $rows,
];
$total->addAmount($group['sum']);
}
return ['groups' => $groups, 'total' => $total];
}
/**
* Wofür der Beleg steht.
*
* `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
* nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
* Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
* Belegliste nur Platz kosten.
*/
private function purpose(?string $typeOther, ?string $comment): string
{
$parts = [];
foreach ([$typeOther, $comment] as $part) {
if (trim((string) $part) !== '') {
$parts[] = trim((string) $part);
}
}
return implode(' — ', $parts);
}
/**
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
*
* Bewusst nicht {@see Amount::getFormattedAmount()}: Die Methode ersetzt nach `number_format` jeden
* Punkt durch ein Komma und macht aus 1.487,50 damit "1,487,50". Auf einer Aufstellung, in der
* vierstellige Beträge die Regel sind, wäre das nicht lesbar. Der Fehler steckt im Value Object und
* wirkt überall, wo Beträge angezeigt werden -- ihn dort zu beheben ist eine eigene Änderung.
*
* Öffentlich, weil die Vorlage sie als Callable bekommt und weil sie für sich prüfbar sein soll.
*/
public static function money(Amount $amount): string
{
return number_format(round($amount->getAmount(), 2), 2, ',', '.');
}
/**
* Summiert Zeilen, die je ein `amount` oder `total` tragen.
*
* Über ein frisches Amount-Objekt, weil `Amount::addAmount()` den Empfänger verändert -- die
* Einzelbeträge sollen unangetastet bleiben, sie werden anschließend gedruckt.
*
* @param array<int, array<string, mixed>> $rows
*/
private static function sum(array $rows): Amount
{
$sum = new Amount(0, 'Euro');
foreach ($rows as $row) {
/** @var Amount $amount */
$amount = $row['amount'] ?? $row['total'];
$sum->addAmount($amount);
}
return $sum;
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
use App\Models\Event;
class CreateIncomeSurplusStatementRequest
{
public function __construct(public readonly Event $event)
{
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
use App\ValueObjects\Amount;
class CreateIncomeSurplusStatementResponse
{
public bool $success = false;
public string $filename = '';
public string $pdfContent = '';
public ?string $message = null;
/**
* Die Zahlen, aus denen das PDF entsteht -- Einnahmen-Kategorien, Ausgaben-Gruppen und das Ergebnis.
*
* Sie stehen hier, weil sie das eigentliche Ergebnis der Action sind; das PDF ist nur ihre Darstellung.
* So lässt sich die Rechnung prüfen, ohne ein PDF zerlegen zu müssen.
*
* @var array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}|array{}
*/
public array $income = [];
/**
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
*/
public array $expenses = [];
public ?Amount $result = null;
}
@@ -13,7 +13,7 @@ class GenerateIcalCommand
$participant = $this->request->participant; $participant = $this->request->participant;
$event = $participant->event; $event = $participant->event;
$uid = $participant->identifier . '@' . app('tenant')->slug; $uid = $participant->identifier . '@' . currentTenant()->slug;
$dtStart = $event->start_date->format('Ymd'); $dtStart = $event->start_date->format('Ymd');
$dtEnd = $event->end_date->copy()->addDay()->format('Ymd'); $dtEnd = $event->end_date->copy()->addDay()->format('Ymd');
$now = now()->format('Ymd\THis\Z'); $now = now()->format('Ymd\THis\Z');
@@ -24,7 +24,7 @@ class GenerateIcalCommand
$icalContent = implode("\r\n", [ $icalContent = implode("\r\n", [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
'PRODID:-//' . app('tenant')->name . '//Veranstaltungskalender//DE', 'PRODID:-//' . currentTenant()->name . '//Veranstaltungskalender//DE',
'CALSCALE:GREGORIAN', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'METHOD:PUBLISH',
'BEGIN:VEVENT', 'BEGIN:VEVENT',
@@ -21,11 +21,11 @@ class GenerateIcalForDeadlineCommand {
$icalContent = implode("\r\n", [ $icalContent = implode("\r\n", [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
'PRODID:-//' . app('tenant')->name . '//Veranstaltungskalender//DE', 'PRODID:-//' . currentTenant()->name . '//Veranstaltungskalender//DE',
'CALSCALE:GREGORIAN', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'METHOD:PUBLISH',
'BEGIN:VEVENT', 'BEGIN:VEVENT',
'UID:payment-deadline-' . $event->identifier . '@' . app('tenant')->slug, 'UID:payment-deadline-' . $event->identifier . '@' . currentTenant()->slug,
'DTSTAMP:' . $now, 'DTSTAMP:' . $now,
'DTSTART;VALUE=DATE:' . $dtDate, 'DTSTART;VALUE=DATE:' . $dtDate,
'DTEND;VALUE=DATE:' . $dtDate, 'DTEND;VALUE=DATE:' . $dtDate,
@@ -18,7 +18,7 @@ class SetParticipationFeesCommand {
$this->cleanBefore(); $this->cleanBefore();
$this->request->event->participationFee1()->associate(EventParticipationFee::create([ $this->request->event->participationFee1()->associate(EventParticipationFee::create([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->participationFeeFirst['type'], 'type' => $this->request->participationFeeFirst['type'],
'name' => $this->request->participationFeeFirst['name'], 'name' => $this->request->participationFeeFirst['name'],
'description' => $this->request->participationFeeFirst['description'], 'description' => $this->request->participationFeeFirst['description'],
@@ -29,7 +29,7 @@ class SetParticipationFeesCommand {
if ($this->request->participationFeeSecond !== null) { if ($this->request->participationFeeSecond !== null) {
$this->request->event->participationFee2()->associate(EventParticipationFee::create([ $this->request->event->participationFee2()->associate(EventParticipationFee::create([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->participationFeeSecond['type'], 'type' => $this->request->participationFeeSecond['type'],
'name' => $this->request->participationFeeSecond['name'], 'name' => $this->request->participationFeeSecond['name'],
'description' => $this->request->participationFeeSecond['description'], 'description' => $this->request->participationFeeSecond['description'],
@@ -41,7 +41,7 @@ class SetParticipationFeesCommand {
if ($this->request->participationFeeThird !== null) { if ($this->request->participationFeeThird !== null) {
$this->request->event->participationFee3()->associate(EventParticipationFee::create([ $this->request->event->participationFee3()->associate(EventParticipationFee::create([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->participationFeeThird['type'], 'type' => $this->request->participationFeeThird['type'],
'name' => $this->request->participationFeeThird['name'], 'name' => $this->request->participationFeeThird['name'],
'description' => $this->request->participationFeeThird['description'], 'description' => $this->request->participationFeeThird['description'],
@@ -53,7 +53,7 @@ class SetParticipationFeesCommand {
if ($this->request->participationFeeFourth !== null) { if ($this->request->participationFeeFourth !== null) {
$this->request->event->participationFee4()->associate(EventParticipationFee::create([ $this->request->event->participationFee4()->associate(EventParticipationFee::create([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->participationFeeFourth['type'], 'type' => $this->request->participationFeeFourth['type'],
'name' => $this->request->participationFeeFourth['name'], 'name' => $this->request->participationFeeFourth['name'],
'description' => $this->request->participationFeeFourth['description'], 'description' => $this->request->participationFeeFourth['description'],
@@ -21,7 +21,7 @@ use Illuminate\Http\Request;
class CreateController extends CommonController { class CreateController extends CommonController {
public function __invoke() { public function __invoke() {
return new InertiaProvider('Event/Create', [ return new InertiaProvider('Event/Create', [
'emailAddress' => auth()->user()->email, 'emailAddress' => currentUserOrFail()->email,
'eventAccount' => $this->tenant->account_name, 'eventAccount' => $this->tenant->account_name,
'eventIban' => $this->tenant->account_iban, 'eventIban' => $this->tenant->account_iban,
'eventPayPerDay' => $this->tenant->slug === 'lv' ? true : false, 'eventPayPerDay' => $this->tenant->slug === 'lv' ? true : false,
@@ -0,0 +1,33 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementCommand;
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementRequest;
use App\Scopes\CommonController;
use Illuminate\Http\Response;
class IncomeSurplusStatementController extends CommonController
{
public function __invoke(string $eventId): Response
{
$event = $this->events->getByIdentifier($eventId);
if ($event === null) {
abort(403, 'Zugriff verweigert.');
}
$statementRequest = new CreateIncomeSurplusStatementRequest($event);
$statementCommand = new CreateIncomeSurplusStatementCommand($statementRequest);
$statementResponse = $statementCommand->execute();
if (!$statementResponse->success) {
abort(422, $statementResponse->message ?? 'Die EÜR konnte nicht erstellt werden.');
}
return response($statementResponse->pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $statementResponse->filename . '"',
]);
}
}
@@ -38,7 +38,7 @@ class SendController extends CommonController
} }
} }
$user = auth()->user(); $user = currentUser();
$reportSubject = sprintf('Sendebericht für Nachricht mit Betreff "%s"', $subject); $reportSubject = sprintf('Sendebericht für Nachricht mit Betreff "%s"', $subject);
Mail::to($user->email)->send(new ManualMailsReportMail( Mail::to($user->email)->send(new ManualMailsReportMail(
@@ -35,8 +35,9 @@ class SignupController extends CommonController {
'lastname' => '', 'lastname' => '',
]; ];
if (auth()->check()) { $currentUser = currentUser();
$user = new UserResource(auth()->user())->toArray($request); if ($currentUser !== null) {
$user = new UserResource($currentUser)->toArray($request);
$participantData = [ $participantData = [
'id' => $user['id'], 'id' => $user['id'],
+5
View File
@@ -4,6 +4,7 @@ use App\Domains\Event\Controllers\ArchivedEventsController;
use App\Domains\Event\Controllers\AvailableEventsController; use App\Domains\Event\Controllers\AvailableEventsController;
use App\Domains\Event\Controllers\CreateController; use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController; use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\IncomeSurplusStatementController;
use App\Domains\Event\Controllers\SignupController; use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -18,6 +19,10 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::get('/details/{eventId}', DetailsController::class); Route::get('/details/{eventId}', DetailsController::class);
// Vor der Wildcard darunter: Sonst greift `downloadPdfList()` und sucht ein Blade namens
// `income-surplus-statement` mit Teilnehmendendaten, die die EÜR gar nicht braucht.
Route::get('/details/{eventId}/pdf/income-surplus-statement', IncomeSurplusStatementController::class);
Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']); Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']);
Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']); Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']);
}); });
@@ -106,6 +106,10 @@ async function showEventAddons() {
<input type="button" value="Beitragsliste (PDF)" /> <input type="button" value="Beitragsliste (PDF)" />
</a><br/> </a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
<input type="button" value="EüR (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
<input type="button" value="Getränkeliste (PDF)" /> <input type="button" value="Getränkeliste (PDF)" />
</a><br/> </a><br/>
@@ -143,6 +147,10 @@ async function showEventAddons() {
<input type="button" value="Beitragsliste (PDF)" /> <input type="button" value="Beitragsliste (PDF)" />
</a><br/> </a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
<input type="button" value="EüR (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
<input type="button" value="Getränkeliste (PDF)" /> <input type="button" value="Getränkeliste (PDF)" />
</a><br/> </a><br/>
@@ -388,6 +388,14 @@ function saveParticipant() {
<small v-else-if="props.participant.refund.status === 'accepted'"> <small v-else-if="props.participant.refund.status === 'accepted'">
bestätigt am {{ props.participant.refund.acceptedAt }} bestätigt am {{ props.participant.refund.acceptedAt }}
</small> </small>
<!-- Was beim Verband geblieben ist und warum. -->
<small v-if="props.participant.refund.hasRetention" class="retention-note">
<br />Einbehalten: {{ props.participant.refund.retainedAmount }} &ndash;
{{ props.participant.refund.retentionReasonLabel }}<template
v-if="props.participant.refund.retentionReasonNote"
> ({{ props.participant.refund.retentionReasonNote }})</template>
</small>
</td> </td>
</tr> </tr>
</table> </table>
@@ -541,4 +549,8 @@ textarea {
select { select {
width: 262px; width: 262px;
} }
.retention-note {
color: #8a6d00;
}
</style> </style>
@@ -10,6 +10,7 @@ import AmountInput from "../../../../Views/Components/AmountInput.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue"; import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue"; import ErrorText from "../../../../Views/Components/ErrorText.vue";
import IbanInput from "../../../../Views/Components/IbanInput.vue";
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -47,17 +48,85 @@ const openCancelDialog = ref(false);
const openPartialPaymentDialogSwitch = ref(false); const openPartialPaymentDialogSwitch = ref(false);
const openRefundDialogSwitch = ref(false); const openRefundDialogSwitch = ref(false);
// Der Erstattungsdialog. Betrag und Grund werden hier gesetzt; die Bankverbindung erfasst der Teili // Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über
// selbst über den Link, den die Freigabe ihm schickt. // den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits
const refundForm = reactive({amount: '', reason: '', reasonNote: ''}); // vor -- dann wird die Erstattung sofort eingereicht.
const refundErrors = reactive({amount: '', reason: '', reasonNote: ''}); const refundForm = reactive({
amount: '', reason: '', reasonNote: '',
captureMode: 'participant', accountOwner: '', accountIban: '',
retentionReason: '', retentionReasonNote: '',
});
const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''});
const refundReasons = ref([]); const refundReasons = ref([]);
const retentionReasons = ref([]);
const refundSaving = ref(false); const refundSaving = ref(false);
const selectedRefundReason = computed( const selectedRefundReason = computed(
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null () => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
); );
const selectedRetentionReason = computed(
() => retentionReasons.value.find(r => r.value === refundForm.retentionReason) ?? null
);
/** Was nach der Erstattung beim Verband bleibt -- die Grundlage für den Einbehaltungsblock. */
const retainedAmount = computed(() => {
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
const refunded = Number((refundForm.amount ?? '').replace(',', '.'));
if (!Number.isFinite(refunded)) {
return 0;
}
const remaining = Math.round((paid - refunded) * 100) / 100;
return remaining > 0.005 ? remaining : 0;
});
const hasRetention = computed(() => retainedAmount.value > 0);
const retainedAmountReadable = computed(
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro'
);
/**
* Ob abgesendet werden kann. Der Knopf erscheint erst dann -- was noch fehlt, soll die Aktionsleitung
* sehen, bevor sie klickt, statt danach eine Fehlermeldung zu lesen.
*/
const refundFormComplete = computed(() => {
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
if (!refundForm.amount || !(amount > 0) || amount > paid + 0.005) {
return false;
}
if (!refundForm.reason) {
return false;
}
if (selectedRefundReason.value?.requiresNote && !refundForm.reasonNote.trim()) {
return false;
}
// Bleibt etwas beim Verband, muss begründet sein, warum.
if (hasRetention.value) {
if (!refundForm.retentionReason) {
return false;
}
if (selectedRetentionReason.value?.requiresNote && !refundForm.retentionReasonNote.trim()) {
return false;
}
}
if (refundForm.captureMode === 'management') {
return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== '';
}
return true;
});
defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete']) defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete'])
function openParticipantDetails(input) { function openParticipantDetails(input) {
@@ -317,15 +386,25 @@ async function openRefundDialog(participant) {
refundForm.amount = participant.amountPaid?.short ?? ''; refundForm.amount = participant.amountPaid?.short ?? '';
refundForm.reason = ''; refundForm.reason = '';
refundForm.reasonNote = ''; refundForm.reasonNote = '';
refundErrors.amount = ''; // Vorgabe ist der übliche Weg über den Teili.
refundErrors.reason = ''; refundForm.captureMode = 'participant';
refundErrors.reasonNote = ''; refundForm.accountOwner = '';
refundForm.accountIban = '';
refundForm.retentionReason = '';
refundForm.retentionReasonNote = '';
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
if (refundReasons.value.length === 0) { if (refundReasons.value.length === 0) {
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'}); const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
refundReasons.value = reasons ?? []; refundReasons.value = reasons ?? [];
} }
if (retentionReasons.value.length === 0) {
const reasons = await request('/api/v1/core/retrieve-retention-reasons', {method: 'GET'});
retentionReasons.value = reasons ?? [];
}
openRefundDialogSwitch.value = true; openRefundDialogSwitch.value = true;
} }
@@ -333,9 +412,7 @@ function validateRefund() {
const amount = Number((refundForm.amount ?? '').replace(',', '.')); const amount = Number((refundForm.amount ?? '').replace(',', '.'));
const paid = Number(showParticipant.value?.amountPaidValue ?? 0); const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
refundErrors.amount = ''; Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
refundErrors.reason = '';
refundErrors.reasonNote = '';
if (!refundForm.amount || !(amount > 0)) { if (!refundForm.amount || !(amount > 0)) {
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.'; refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
@@ -349,7 +426,19 @@ function validateRefund() {
refundErrors.reasonNote = 'Bitte erläutere den Grund.'; refundErrors.reasonNote = 'Bitte erläutere den Grund.';
} }
return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote; // Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
if (refundForm.captureMode === 'management') {
if (!refundForm.accountOwner.trim()) {
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
}
if (!refundForm.accountIban.trim()) {
refundErrors.accountIban = 'Bitte gib die IBAN des Kontos ein.';
}
}
return Object.values(refundErrors).every(message => !message);
} }
async function execRefund() { async function execRefund() {
@@ -366,13 +455,25 @@ async function execRefund() {
amount: refundForm.amount, amount: refundForm.amount,
reason: refundForm.reason, reason: refundForm.reason,
reasonNote: refundForm.reasonNote, reasonNote: refundForm.reasonNote,
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
retentionReason: hasRetention.value ? refundForm.retentionReason : '',
retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '',
}, },
}); });
if (data?.status === 'success') { if (data?.status === 'success') {
toast.success(data.message); toast.success(data.message);
// Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm. // Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
// Beim Direktweg steht dort sofort "Erstattet" samt Abrechnungsnummer.
showParticipant.value.refund = data.refund; showParticipant.value.refund = data.refund;
// Der gezahlte Beitrag wird beim Einreichen auf 0 gesetzt -- sonst zeigte die Zeile weiter
// den alten Stand, bis jemand neu lädt.
if (data.refund?.status === 'accepted') {
showParticipant.value.amountPaidValue = 0;
}
openRefundDialogSwitch.value = false; openRefundDialogSwitch.value = false;
} else { } else {
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.'); toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
@@ -455,6 +556,15 @@ function mailToGroup(groupKey) {
<td class="pl-amount" :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''"> <td class="pl-amount" :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''">
Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br /> Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br />
Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label> Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label>
<!-- Warum ein Teil des Beitrags beim Verband geblieben ist. -->
<span v-if="participant.refund?.hasRetention" class="retention-note">
Einbehalten: {{ participant.refund.retainedAmount }}<br />
{{ participant.refund.retentionReasonLabel }}<template
v-if="participant.refund.retentionReasonNote"
> &ndash; {{ participant.refund.retentionReasonNote }}</template>
</span>
<br /><br /> <br /><br />
<span v-if="participant.amount_left_value != 0 && !participant.unregistered" :id="'participant-' + participant.identifier + '-actions'"> <span v-if="participant.amount_left_value != 0 && !participant.unregistered" :id="'participant-' + participant.identifier + '-actions'">
<span class="link" style="font-size:10pt;" @click="paymentComplete(participant)">Zahlung buchen</span> &nbsp; <span class="link" style="font-size:10pt;" @click="paymentComplete(participant)">Zahlung buchen</span> &nbsp;
@@ -524,6 +634,9 @@ function mailToGroup(groupKey) {
<template v-else-if="participant.refund?.status === 'accepted'"> <template v-else-if="participant.refund?.status === 'accepted'">
| <strong>Erstattet:</strong> {{ participant.refund.amount }} | <strong>Erstattet:</strong> {{ participant.refund.amount }}
<template v-if="participant.refund.invoiceNumber">
&middot; Abrechnung {{ participant.refund.invoiceNumber }}
</template>
<span class="link" @click="downloadRefundDocument(participant)">Beleg</span> <span class="link" @click="downloadRefundDocument(participant)">Beleg</span>
</template> </template>
</template> </template>
@@ -600,8 +713,7 @@ function mailToGroup(groupKey) {
> >
<p class="refund-intro"> <p class="refund-intro">
{{ showParticipant?.fullname }} hat {{ showParticipant?.fullname }} hat
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt. Nach der Freigabe erhält <strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt.
der Teili eine E-Mail und trägt seine Bankverbindung selbst ein.
</p> </p>
<div class="refund-field"> <div class="refund-field">
@@ -629,8 +741,85 @@ function mailToGroup(groupKey) {
<ErrorText :message="refundErrors.reasonNote" /> <ErrorText :message="refundErrors.reasonNote" />
</div> </div>
<button class="button" :disabled="refundSaving" @click="execRefund()"> <!--
{{ refundSaving ? 'Wird freigegeben' : 'Erstattung freigeben' }} Bleibt ein Teil beim Verband, muss begründet sein, warum -- ein einbehaltener Betrag ohne
Grund ist in der Buchhaltung nicht haltbar. Bei voller Erstattung gibt es nichts zu zeigen.
-->
<template v-if="hasRetention">
<p class="refund-hint">
<strong>{{ retainedAmountReadable }}</strong> verbleiben beim Verband.
</p>
<div class="refund-field">
<label for="refund_retention_reason">Grund der Einbehaltung</label>
<select id="refund_retention_reason" v-model="refundForm.retentionReason" class="form-input">
<option value="">Bitte auswählen </option>
<option v-for="reason in retentionReasons" :key="reason.value" :value="reason.value">
{{ reason.label }}
</option>
</select>
</div>
<div v-if="selectedRetentionReason?.requiresNote" class="refund-field">
<label for="refund_retention_note">Erläuterung zur Einbehaltung</label>
<textarea
id="refund_retention_note"
v-model="refundForm.retentionReasonNote"
class="form-input"
rows="3"
></textarea>
</div>
</template>
<!--
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird
sofort eingereicht. Er bekommt den Beleg trotzdem.
-->
<div class="refund-field">
<label class="refund-choice">
<input type="radio" value="participant" v-model="refundForm.captureMode" />
Teilnehmer*in trägt die Bankverbindung selbst ein
</label>
<label class="refund-choice">
<input type="radio" value="management" v-model="refundForm.captureMode" />
Bankverbindung liegt mir vor
</label>
</div>
<template v-if="refundForm.captureMode === 'management'">
<div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label>
<input
id="refund_account_owner"
v-model="refundForm.accountOwner"
type="text"
class="form-input"
/>
<ErrorText :message="refundErrors.accountOwner" />
</div>
<div class="refund-field">
<label for="refund_account_iban">IBAN</label>
<IbanInput id="refund_account_iban" v-model="refundForm.accountIban" class="form-input" />
<ErrorText :message="refundErrors.accountIban" />
</div>
<p class="refund-hint">
Die Erstattung wird sofort als Abrechnung eingereicht. Der Teili erhält den Beleg per
E-Mail und kann die Angaben prüfen.
</p>
</template>
<!-- Erscheint erst, wenn alles ausgefüllt ist; während des Speicherns gesperrt statt weg. -->
<button
v-if="refundFormComplete"
class="button"
:disabled="refundSaving"
@click="execRefund()"
>
<template v-if="refundSaving">Wird gespeichert</template>
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
<template v-else>Erstattung freigeben</template>
</button> </button>
</Modal> </Modal>
@@ -665,10 +854,40 @@ function mailToGroup(groupKey) {
} }
.refund-field select, .refund-field select,
.refund-field textarea { .refund-field textarea,
.refund-field .form-input {
width: 100%; width: 100%;
} }
.refund-choice {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #1a1a1a;
cursor: pointer;
}
.refund-choice input {
margin-right: 6px;
}
.retention-note {
display: block;
margin-top: 6px;
font-size: 10pt;
color: #ca5a0a;
line-height: 1.4;
}
.refund-hint {
margin-bottom: 14px;
padding: 8px 10px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.85rem;
color: #4b5563;
}
.participants-table { .participants-table {
width: 95%; width: 95%;
margin: 20px auto; margin: 20px auto;
@@ -68,6 +68,17 @@ const props = defineProps({
</td> </td>
</tr> </tr>
<!--
Beiträge, die trotz Abmeldung beim Verband geblieben sind. Eigene Zeile, weil die
Zeilen darüber nur aktive Anmeldungen führen.
-->
<tr v-if="props.event.retainedFromUnregistered.value > 0">
<th style="padding-bottom: 20px" colspan="2">Einbehalten von Abmeldungen</th>
<td style="padding-bottom: 20px" colspan="2">
{{ props.event.retainedFromUnregistered.readable }}
</td>
</tr>
<tr> <tr>
<th colspan="2" style="border-width: 1px; border-top-style: solid">Gesamt</th> <th colspan="2" style="border-width: 1px; border-top-style: solid">Gesamt</th>
<td style="font-weight: bold; border-width: 1px; border-top-style: solid"> <td style="font-weight: bold; border-width: 1px; border-top-style: solid">
@@ -22,7 +22,7 @@ class ChangeStatusCommand {
switch ($this->request->status) { switch ($this->request->status) {
case InvoiceStatus::INVOICE_STATUS_APPROVED: case InvoiceStatus::INVOICE_STATUS_APPROVED:
$this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_APPROVED; $this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_APPROVED;
$this->request->invoice->approved_by = auth()->user()->id; $this->request->invoice->approved_by = currentUserOrFail()->id;
$this->request->invoice->approved_at = now(); $this->request->invoice->approved_at = now();
if ($this->request->invoice->contact_email !== null) { if ($this->request->invoice->contact_email !== null) {
@@ -35,7 +35,7 @@ class ChangeStatusCommand {
case InvoiceStatus::INVOICE_STATUS_DENIED: case InvoiceStatus::INVOICE_STATUS_DENIED:
$this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_DENIED; $this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_DENIED;
$this->request->invoice->denied_by = auth()->user()->id; $this->request->invoice->denied_by = currentUserOrFail()->id;
$this->request->invoice->denied_at = now(); $this->request->invoice->denied_at = now();
$this->request->invoice->denied_reason = $this->request->comment; $this->request->invoice->denied_reason = $this->request->comment;
if ($this->request->invoice->contact_email !== null) { if ($this->request->invoice->contact_email !== null) {
@@ -24,7 +24,7 @@ class CreateInvoiceCommand {
} }
$invoice = Invoice::create([ $invoice = Invoice::create([
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
'cost_unit_id' => $this->request->costUnit->id, 'cost_unit_id' => $this->request->costUnit->id,
'invoice_number' => $this->generateInvoiceNumber(), 'invoice_number' => $this->generateInvoiceNumber(),
'status' => InvoiceStatus::INVOICE_STATUS_NEW, 'status' => InvoiceStatus::INVOICE_STATUS_NEW,
@@ -61,7 +61,7 @@ class CreateInvoiceCommand {
} }
if ($this->request->costUnit->mail_on_new) { if ($this->request->costUnit->mail_on_new) {
$recipients = [app('tenant')->email_finance]; $recipients = [currentTenant()->email_finance];
foreach ($this->request->costUnit->treasurers()->get() as $treasurer) { foreach ($this->request->costUnit->treasurers()->get() as $treasurer) {
if (!in_array($treasurer->email, $recipients)) { if (!in_array($treasurer->email, $recipients)) {
@@ -83,7 +83,7 @@ class CreateInvoiceCommand {
private function generateInvoiceNumber() : string { private function generateInvoiceNumber() : string {
$lastInvoiceNumber = Invoice::query() $lastInvoiceNumber = Invoice::query()
->where('tenant', app('tenant')->slug) ->where('tenant', currentTenant()->slug)
->whereYear('created_at', date('Y')) ->whereYear('created_at', date('Y'))
->count(); ->count();
@@ -19,7 +19,7 @@ class UploadInvoiceCommand {
$uploadDir = sprintf( $uploadDir = sprintf(
'%1$s%2$s/%3$s', '%1$s%2$s/%3$s',
WebDavProvider::INVOICE_PREFIX, WebDavProvider::INVOICE_PREFIX,
app('tenant')->url, currentTenant()->url,
$this->request->invoice->costUnit()->first()->name $this->request->invoice->costUnit()->first()->name
); );
@@ -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"
@@ -35,7 +35,7 @@ class CreateParticipantInvoiceCommand
$this->participant = $request->participant; $this->participant = $request->participant;
$this->event = $request->participant->event; $this->event = $request->participant->event;
// Über die Relation und nicht über app('tenant'): die Rechnung hängt an der Veranstaltung, // Über die Relation und nicht über currentTenant(): die Rechnung hängt an der Veranstaltung,
// nicht am gerade aktiven Mandanten. `$event->tenant` liefert das Slug-Attribut, nicht die // nicht am gerade aktiven Mandanten. `$event->tenant` liefert das Slug-Attribut, nicht die
// Relation -- deshalb der ausdrückliche Aufruf. // Relation -- deshalb der ausdrückliche Aufruf.
$this->sender = $this->event->tenant()->first(); $this->sender = $this->event->tenant()->first();
@@ -2,12 +2,27 @@
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund; namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand; use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest; use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentResponse;
use App\Enumerations\InvoiceType;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail; use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Providers\FileWriteProvider;
use App\Providers\UploadFileProvider;
use App\Repositories\CostUnitRepository;
use App\Support\Iban; use App\Support\Iban;
use App\ValueObjects\Amount;
use App\ValueObjects\InvoiceFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use RuntimeException;
/** /**
* Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung. * Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung.
@@ -52,7 +67,10 @@ class AcceptRefundCommand
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg // Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf // als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
// dem PDF eine Zusicherung, die niemand abgegeben hat. // dem PDF eine Zusicherung, die niemand abgegeben hat.
if (!$this->request->declarationAccepted) { //
// Nimmt die Aktionsleitung die Angaben auf, kreuzt naturgemäß niemand etwas an. Nachvollziehbar
// bleibt es trotzdem: `captured_by` hält fest, wer sie aufgenommen hat, und der Beleg weist es aus.
if (!$this->request->declarationAccepted && $this->request->capturedBy === null) {
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.'; $response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
} }
@@ -72,13 +90,46 @@ class AcceptRefundCommand
return $response; return $response;
} }
$refund->account_owner = $owner; // Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den
$refund->account_iban = $iban; // Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen.
$refund->status = ParticipantRefund::STATUS_ACCEPTED; $costUnit = $this->costUnit($refund);
$refund->accepted_at = now(); if ($costUnit === null) {
$refund->save(); $response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. '
. 'Bitte wende dich an die Aktionsleitung.';
$this->notify($refund); Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [
'refund_id' => $refund->id,
'event_id' => $refund->event_id,
]);
return $response;
}
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
$refund->account_owner = $owner;
$refund->account_iban = $iban;
$refund->captured_by = $this->request->capturedBy;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
$invoice = $this->createInvoice($refund, $costUnit, $document);
// Erst jetzt, nicht früher: Beleg und Anmerkung der Abrechnung weisen den gezahlten Beitrag
// aus und läsen sonst bereits den verrechneten Stand.
$this->settleAmountPaid($refund);
$refund->invoice_id = $invoice->id;
$refund->save();
return $document;
});
$this->notify($refund, $document);
$response->success = true; $response->success = true;
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.'; $response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
@@ -87,30 +138,169 @@ class AcceptRefundCommand
} }
/** /**
* Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson. * Die Kostenstelle der Veranstaltung.
* *
* Scheitert die Belegerzeugung, geht die Mail trotzdem raus -- der Vorgang ist gespeichert, und die * Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token.
* Aktionsleitung kann den Beleg jederzeit erneut abrufen. Ein Fehler hier darf nicht dazu führen, * Der Repository-Check greift sonst auf `currentUserOrFail()->id` zu und liefe in einen Fehler.
* dass der Teili gar nichts hört. *
* Bewusst ohne Prüfung auf `allow_new`/`archived`: Eine Erstattung fällt oft erst nach dem Ende der
* Veranstaltung an, wenn die Kostenstelle längst geschlossen ist. Sie gehört trotzdem dorthin -- und
* der reguläre Weg über SaveInvoiceController prüft das ebenso wenig.
*/ */
private function notify(ParticipantRefund $refund): void private function costUnit(ParticipantRefund $refund): ?CostUnit
{ {
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); if ($refund->event->cost_unit_id === null) {
return null;
}
return new CostUnitRepository()->getById($refund->event->cost_unit_id, true);
}
/**
* Reicht die Erstattung als gewöhnliche Auslagenabrechnung ein.
*
* Über denselben Command wie jede von Hand erfasste Abrechnung: damit stimmen Nummernkreis, Status
* `new`, die Bestätigungsmail an den Teili und die Benachrichtigung der Kassenwart*innen mit dem
* überein, was die Buchhaltung kennt.
*/
private function createInvoice(
ParticipantRefund $refund,
CostUnit $costUnit,
CreateRefundDocumentResponse $document,
): Invoice {
$participant = $refund->participant;
$invoiceRequest = new CreateInvoiceRequest(
costUnit: $costUnit,
// getOfficialName() und nicht getFullName(): letzteres enthält HTML für die Oberfläche.
contactName: $participant->getOfficialName(),
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0,
receiptFile: $this->storeReceipt($costUnit, $document),
isDonation: false,
userId: $participant->user_id,
contactEmail: $participant->email_1,
contactPhone: $participant->phone_1,
// Die Bankverbindung stammt aus dem Vorgang, nicht vom Teilnehmer: das Konto kann einem
// Elternteil gehören.
accountOwner: $refund->account_owner,
accountIban: $refund->account_iban,
// Die folgenden vier gehören zu Reisekosten und Freitext-Typen und sind hier leer. Sie
// müssen trotzdem stehen: `transportations` hat als einziger Parameter keinen Vorgabewert,
// und PHP macht damit auch alle optionalen Parameter davor zu Pflichtangaben.
invoiceTypeExtended: null,
travelRoute: null,
distance: null,
passengers: null,
transportations: null,
// MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas
// steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen".
paymentPurpose: null,
notices: $this->notice($refund),
);
$invoiceResponse = new CreateInvoiceCommand($invoiceRequest)->execute();
if (!$invoiceResponse->success || $invoiceResponse->invoice === null) {
// Rollt die Transaktion zurück -- der Vorgang bleibt offen, der Teili kann es erneut versuchen.
throw new RuntimeException('Die Abrechnung zur Beitragserstattung konnte nicht angelegt werden.');
}
return $invoiceResponse->invoice;
}
/**
* Legt den Eigenbeleg dort ab, wo auch hochgeladene Belege liegen, und verpackt ihn für die
* Abrechnung. `CreateInvoiceCommand` speichert nur den Pfad und schreibt selbst keine Dateien.
*/
private function storeReceipt(CostUnit $costUnit, CreateRefundDocumentResponse $document): ?InvoiceFile
{
if (!$document->success) {
return null;
}
$path = UploadFileProvider::directoryFor($costUnit) . '/' . $document->filename;
new FileWriteProvider($path, $document->pdfContent)->writeToFile();
$receipt = new InvoiceFile();
// Beide Eigenschaften sind typisiert und ohne Vorbelegung; gespeichert wird nur `fullPath`.
$receipt->filename = $document->filename;
$receipt->fullPath = $path;
return $receipt;
}
/**
* Die Anmerkung auf der Abrechnung.
*
* Sie nennt den gezahlten Beitrag, weil er am Teilnehmer gleich auf 0 gesetzt wird
* ({@see self::clearAmountPaid()}) -- die Schatzmeisterei kann den Vorgang so nachvollziehen, ohne
* den vorherigen Stand irgendwo suchen zu müssen.
*
* Gekürzt wird nur der vordere, freie Teil: Veranstaltungsname und Grund sind beliebig lang, der
* Betrag darf nie abgeschnitten werden.
*/
private function notice(ParticipantRefund $refund): string
{
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro';
return Str::limit(sprintf(
'Rückerstattung Teilnahmebeitrag %s %s',
$refund->event->name,
$refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
}
/**
* Zieht den erstatteten Betrag vom gezahlten Beitrag ab.
*
* Danach führt `amount_paid` genau das, was beim Verband geblieben ist -- bei voller Erstattung also
* 0, bei einer Teilerstattung den einbehaltenen Rest. Auf diesem Feld baut die Einnahmenrechnung der
* Veranstaltung auf; es muss deshalb den tatsächlichen Bestand abbilden und nicht die Zahlung von
* einst. Der ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem
* Beleg.
*/
private function settleAmountPaid(ParticipantRefund $refund): void
{
$participant = $refund->participant;
$paid = $participant->amount_paid?->getAmount() ?? 0.0;
$refunded = $refund->amount?->getAmount() ?? 0.0;
// `max` gegen Rundungsreste: Ein negativer gezahlter Betrag wäre in jeder Auswertung Unsinn.
$participant->amount_paid = new Amount(max(0.0, round($paid - $refunded, 2)), 'Euro');
$participant->save();
}
/**
* Die eigene Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
*
* Sie kommt zusätzlich zu der, die CreateInvoiceCommand verschickt: diese trägt den Beleg, jene ist
* die Quittung des Abrechnungssystems. Erst nach der Transaktion, damit nichts verschickt wird, was
* anschließend zurückgerollt würde.
*
* Scheitert die Belegerzeugung, geht die Mail ohne Anhang raus statt gar nicht.
*/
private function notify(ParticipantRefund $refund, CreateRefundDocumentResponse $document): void
{
$pdf = $document->success ? $document->pdfContent : null; $pdf = $document->success ? $document->pdfContent : null;
$filename = $document->success ? $document->filename : null; $filename = $document->success ? $document->filename : null;
$participant = $refund->participant; $participant = $refund->participant;
Mail::to($participant->email_1)->send(new RefundAcceptedMail( $recipients = [$participant->email_1];
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
if ($participant->email_2 !== null) { // `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
Mail::to($participant->email_2)->send(new RefundAcceptedMail( // Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere.
if (filled($participant->email_2)) {
$recipients[] = $participant->email_2;
}
foreach ($recipients as $recipient) {
Mail::to($recipient)->send(new RefundAcceptedMail(
participant: $participant, participant: $participant,
refund: $refund, refund: $refund,
pdfContent: $pdf, pdfContent: $pdf,
@@ -12,6 +12,13 @@ class AcceptRefundRequest
public readonly string $accountIban, public readonly string $accountIban,
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */ /** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
public readonly bool $declarationAccepted = false, public readonly bool $declarationAccepted = false,
/**
* Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag.
*
* Dann kreuzt niemand die Erklärung an -- sie wird stellvertretend aufgenommen, und der Beleg
* weist genau das aus.
*/
public readonly ?int $capturedBy = null,
) { ) {
} }
} }
@@ -104,6 +104,48 @@ class CreateRefundDocumentCommand
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der * Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen. * Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
*/ */
/**
* Der Hinweis, warum ein Teil des Beitrags beim Verband bleibt -- leer bei voller Erstattung.
*
* Der Beleg wandert in die Buchhaltung und ins Archiv; dort muss die Differenz zwischen gezahltem
* und erstattetem Betrag ohne Rückfrage erklärt sein.
*/
private function retentionNote(): string
{
if (!$this->refund->hasRetention()) {
return '';
}
$text = trim($this->refund->retentionReasonText());
$label = $this->refund->retentionReasonLabel();
return $text !== '' && $text !== $label
? sprintf('%s (%s)', $label, $text)
: $label;
}
/**
* Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat.
*
* Er nennt Name und Datum, weil in diesem Fall niemand die Erklärung darüber angekreuzt hat: Wer den
* Beleg prüft, soll erkennen, dass dort eine aufgenommene Angabe steht und keine Bestätigung des
* Teilis selbst. Beim gewöhnlichen Weg bleibt der Platzhalter leer und der Block fällt weg.
*/
private function captureNote(): string
{
if (!$this->refund->wasCapturedByManagement()) {
return '';
}
$name = $this->refund->capturedBy()->first()?->getOfficialName();
return sprintf(
'Angaben aufgenommen durch %s am %s.',
trim((string) $name) !== '' ? $name : 'die Aktionsleitung',
$this->refund->accepted_at?->format('d.m.Y') ?? ''
);
}
private function declarationText(): string private function declarationText(): string
{ {
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content; $text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
@@ -177,7 +219,11 @@ class CreateRefundDocumentCommand
'account_owner' => (string) $refund->account_owner, 'account_owner' => (string) $refund->account_owner,
'account_iban' => $this->formatIban((string) $refund->account_iban), 'account_iban' => $this->formatIban((string) $refund->account_iban),
'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
'retention_note' => $this->retentionNote(),
'declaration_text' => $this->declarationText(), 'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(),
'details_table' => $this->renderDetails(), 'details_table' => $this->renderDetails(),
]; ];
@@ -219,6 +265,13 @@ class CreateRefundDocumentCommand
$rows[] = ['Begründung', e($reasonText)]; $rows[] = ['Begründung', e($reasonText)];
} }
// Nur bei einer Teilerstattung: Ohne diese Zeile bliebe die Differenz zwischen gezahltem und
// erstattetem Betrag im Beleg unerklärt.
if ($refund->hasRetention()) {
$rows[] = ['Einbehalten', $this->money($refund->retained_amount?->getAmount() ?? 0.0)];
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
}
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)]; $rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))]; $rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
@@ -2,13 +2,19 @@
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund; namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Mail\ParticipantRefundMails\RefundReleasedMail; use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\ParticipantRefund; use App\Models\ParticipantRefund;
use App\Repositories\ParticipantRefundRepository; use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException;
/** /**
* Gibt die Erstattung eines Teilnahmebeitrags frei. * Gibt die Erstattung eines Teilnahmebeitrags frei.
@@ -40,28 +46,71 @@ class ReleaseRefundCommand
return $response; return $response;
} }
$refund = ParticipantRefund::create([ // Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die,
'tenant' => $this->participant->tenant, // soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion.
'event_id' => $this->participant->event_id, $refund = DB::transaction(function (): ParticipantRefund {
'event_participant_id' => $this->participant->id, $refund = ParticipantRefund::create([
'token' => Str::random(32), 'tenant' => $this->participant->tenant,
'status' => ParticipantRefund::STATUS_PENDING, 'event_id' => $this->participant->event_id,
'amount' => $this->request->amount, 'event_participant_id' => $this->participant->id,
'reason' => $this->request->reason, 'token' => Str::random(32),
'reason_note' => $this->reasonNote(), 'status' => ParticipantRefund::STATUS_PENDING,
'released_by' => auth()->id(), 'amount' => $this->request->amount,
'released_at' => now(), 'reason' => $this->request->reason,
]); 'reason_note' => $this->reasonNote(),
// Was beim Verband bleibt, wird hier festgeschrieben: Nach dem Einreichen führt
// `amount_paid` bereits diesen Rest, eine spätere Differenz wäre falsch.
'retained_amount' => $this->request->retainedAmount(),
'retention_reason' => $this->retentionReason(),
'retention_reason_note' => $this->retentionReasonNote(),
'released_by' => currentUser()?->id,
'released_at' => now(),
]);
$this->notify($refund); if ($this->request->hasBankDetails()) {
$this->submitDirectly($refund);
}
return $refund;
});
if (!$this->request->hasBankDetails()) {
$this->notify($refund);
}
$response->success = true; $response->success = true;
$response->refund = $refund; $response->refund = $refund->fresh();
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.'; $response->message = $this->request->hasBankDetails()
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
return $response; return $response;
} }
/**
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
*
* Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen
* des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab.
*/
private function submitDirectly(ParticipantRefund $refund): void
{
$acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: (string) $this->request->accountOwner,
accountIban: (string) $this->request->accountIban,
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus.
capturedBy: currentUser()?->id,
))->execute();
if (!$acceptResponse->success) {
// Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen
// Vorgang vorfinden, der nirgends eingereicht ist.
throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.');
}
}
/** /**
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist. * Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
* *
@@ -98,6 +147,59 @@ class ReleaseRefundCommand
return 'Für diesen Grund ist eine Erläuterung erforderlich.'; return 'Für diesen Grund ist eine Erläuterung erforderlich.';
} }
return $this->rejectRetention() ?? $this->rejectBankDetails();
}
/**
* Prüfungen zum einbehaltenen Teil.
*
* Sicherheitsnetz hinter der Oberfläche: Dort erscheint der Absende-Knopf erst, wenn ein Grund
* gewählt ist. Über einen direkten Aufruf ginge das sonst vorbei, und ein einbehaltener Betrag ohne
* Begründung ist in der Buchhaltung nicht haltbar.
*/
private function rejectRetention(): ?string
{
if (!$this->request->hasRetention()) {
return null;
}
$reason = RetentionReason::find($this->request->retentionReason);
if ($reason === null) {
return 'Bitte gib an, warum ein Teil des Beitrags einbehalten wird.';
}
if ($reason->requires_note && trim((string) $this->request->retentionReasonNote) === '') {
return 'Für diesen Einbehaltungsgrund ist eine Erläuterung erforderlich.';
}
return null;
}
/**
* Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt
* also keine zweite Gelegenheit, Angaben zu berichtigen.
*/
private function rejectBankDetails(): ?string
{
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
}
if (!$this->request->hasBankDetails()) {
return null;
}
if (!Iban::isValid((string) $this->request->accountIban)) {
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.';
}
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
if ($this->participant->event->cost_unit_id === null) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
}
return null; return null;
} }
@@ -113,19 +215,49 @@ class ReleaseRefundCommand
return trim((string) $this->request->reasonNote); return trim((string) $this->request->reasonNote);
} }
/**
* Der Einbehaltungsgrund -- nur, wenn tatsächlich etwas beim Verband bleibt.
*
* Bei voller Erstattung wird ein mitgeschickter Grund verworfen: In der Oberfläche ist das Feld dann
* gar nicht sichtbar, und ein Wert ohne Bezug hätte in der Datenbank nichts zu suchen.
*/
private function retentionReason(): ?string
{
return $this->request->hasRetention() ? $this->request->retentionReason : null;
}
/** Der Freitext dazu -- wie beim Erstattungsgrund nur bei Gründen, die ihn verlangen. */
private function retentionReasonNote(): ?string
{
if (!$this->request->hasRetention()) {
return null;
}
$reason = RetentionReason::find($this->request->retentionReason);
if ($reason === null || !$reason->requires_note) {
return null;
}
return trim((string) $this->request->retentionReasonNote);
}
/** /**
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung * Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung
* (siehe SetParticipationStateCommand). * (siehe SetParticipationStateCommand).
*/ */
private function notify(ParticipantRefund $refund): void private function notify(ParticipantRefund $refund): void
{ {
Mail::to($this->participant->email_1)->send(new RefundReleasedMail( $recipients = [$this->participant->email_1];
participant: $this->participant,
refund: $refund,
));
if ($this->participant->email_2 !== null) { // `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
Mail::to($this->participant->email_2)->send(new RefundReleasedMail( // Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere.
if (filled($this->participant->email_2)) {
$recipients[] = $this->participant->email_2;
}
foreach ($recipients as $recipient) {
Mail::to($recipient)->send(new RefundReleasedMail(
participant: $this->participant, participant: $this->participant,
refund: $refund, refund: $refund,
)); ));
@@ -12,6 +12,47 @@ class ReleaseRefundRequest
public readonly Amount $amount, public readonly Amount $amount,
public readonly string $reason, public readonly string $reason,
public readonly ?string $reasonNote = null, public readonly ?string $reasonNote = null,
/**
* Die Bankverbindung, wenn sie der Aktionsleitung bereits vorliegt.
*
* Sind beide gesetzt, entfällt der Umweg über den Teili: die Erstattung wird sofort eingereicht.
* Bleiben sie leer, läuft der übliche Weg über den Bestätigungslink.
*/
public readonly ?string $accountOwner = null,
public readonly ?string $accountIban = null,
/**
* Warum ein Teil des Beitrags beim Verband bleibt.
*
* Pflicht, sobald weniger erstattet wird als gezahlt wurde: Ein einbehaltener Betrag ohne Grund
* ist in der Buchhaltung nicht haltbar.
*/
public readonly ?string $retentionReason = null,
public readonly ?string $retentionReasonNote = null,
) { ) {
} }
/** Ob die Erstattung ohne Zutun des Teilis eingereicht werden kann. */
public function hasBankDetails(): bool
{
return filled($this->accountOwner) && filled($this->accountIban);
}
/**
* Der Betrag, der beim Verband bleibt.
*
* Die halbe Cent-Toleranz fängt die Rundung des gespeicherten Floats ab -- ohne sie entstünden
* Restbeträge von Bruchteilen eines Cents, die eine Begründung verlangen würden.
*/
public function retainedAmount(): float
{
$paid = $this->participant->amount_paid?->getAmount() ?? 0.0;
$remaining = round($paid - $this->amount->getAmount(), 2);
return $remaining > 0.005 ? $remaining : 0.0;
}
public function hasRetention(): bool
{
return $this->retainedAmount() > 0.0;
}
} }
@@ -25,6 +25,12 @@ class ReleaseRefundController extends CommonController
amount: Amount::fromString((string) $request->input('amount'), 'Euro'), amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
reason: (string) $request->input('reason'), reason: (string) $request->input('reason'),
reasonNote: Text::nullIfBlank($request->input('reasonNote')), reasonNote: Text::nullIfBlank($request->input('reasonNote')),
// Leer, wenn der Teili die Bankverbindung selbst eintragen soll.
accountOwner: Text::nullIfBlank($request->input('accountOwner')),
accountIban: Text::nullIfBlank($request->input('accountIban')),
// Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
retentionReason: Text::nullIfBlank($request->input('retentionReason')),
retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')),
); );
$response = new ReleaseRefundCommand($refundRequest)->execute(); $response = new ReleaseRefundCommand($refundRequest)->execute();
@@ -61,11 +61,14 @@ final class ParticipantRefundTokens
'paid_amount' => ['description' => 'Bereits gezahlter Teilnahmebeitrag', 'sample' => '300,00 €'], 'paid_amount' => ['description' => 'Bereits gezahlter Teilnahmebeitrag', 'sample' => '300,00 €'],
'invoice_number' => ['description' => 'Nummer der Teilnahmerechnung', 'sample' => 'WM-V-20260701-0005'], 'invoice_number' => ['description' => 'Nummer der Teilnahmerechnung', 'sample' => 'WM-V-20260701-0005'],
'refund_amount' => ['description' => 'Erstattungsbetrag', 'sample' => '220,00 €'], 'refund_amount' => ['description' => 'Erstattungsbetrag', 'sample' => '220,00 €'],
'retained_amount' => ['description' => 'Betrag, der beim Verband bleibt — 0,00 € bei voller Erstattung', 'sample' => '80,00 €'],
'retention_note' => ['description' => 'Grund der Einbehaltung — leer bei voller Erstattung', 'sample' => 'Stornogebühr laut Ausschreibung'],
'refund_reason' => ['description' => 'Bezeichnung des Grundes', 'sample' => 'Krankheitsbedingte Absage'], 'refund_reason' => ['description' => 'Bezeichnung des Grundes', 'sample' => 'Krankheitsbedingte Absage'],
'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'], 'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'],
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'], 'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'], 'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'], 'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
], ],
], ],
'body' => [ 'body' => [
@@ -110,6 +113,8 @@ final class ParticipantRefundTokens
. '<tr><td class="detail-key">Erstattungsbetrag</td><td class="detail-val">220,00&nbsp;&euro;</td></tr>' . '<tr><td class="detail-key">Erstattungsbetrag</td><td class="detail-val">220,00&nbsp;&euro;</td></tr>'
. '<tr><td class="detail-key">Grund</td><td class="detail-val">Krankheitsbedingte Absage</td></tr>' . '<tr><td class="detail-key">Grund</td><td class="detail-val">Krankheitsbedingte Absage</td></tr>'
. '<tr><td class="detail-key">Begr&uuml;ndung</td><td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td></tr>' . '<tr><td class="detail-key">Begr&uuml;ndung</td><td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td></tr>'
. '<tr><td class="detail-key">Einbehalten</td><td class="detail-val">80,00&nbsp;&euro;</td></tr>'
. '<tr><td class="detail-key">Grund der Einbehaltung</td><td class="detail-val">Stornogeb&uuml;hr laut Ausschreibung</td></tr>'
. '<tr><td class="detail-key">Kontoinhaber*in</td><td class="detail-val">Mika Muster</td></tr>' . '<tr><td class="detail-key">Kontoinhaber*in</td><td class="detail-val">Mika Muster</td></tr>'
. '<tr><td class="detail-key">IBAN</td><td class="detail-val">DE02 1203 0000 0000 2020 51</td></tr>' . '<tr><td class="detail-key">IBAN</td><td class="detail-val">DE02 1203 0000 0000 2020 51</td></tr>'
. '</table>'; . '</table>';
@@ -23,7 +23,7 @@ use Illuminate\Http\Request;
class EmailVerificationController extends CommonController class EmailVerificationController extends CommonController
{ {
public function verifyEmailForm(Request $request) { public function verifyEmailForm(Request $request) {
$inertiaProvider = new InertiaProvider('UserManagement/VerifyEmail', ['appName' => app('tenant')->name]); $inertiaProvider = new InertiaProvider('UserManagement/VerifyEmail', ['appName' => currentTenant()->name]);
return $inertiaProvider->render(); return $inertiaProvider->render();
} }
@@ -17,7 +17,7 @@ class LoginController extends CommonController {
} }
$inertiaProvider = new InertiaProvider('UserManagement/Login', ['errors' => $errors, 'appName' => app('tenant')->name]); $inertiaProvider = new InertiaProvider('UserManagement/Login', ['errors' => $errors, 'appName' => currentTenant()->name]);
return $inertiaProvider->render(); return $inertiaProvider->render();
} }
@@ -45,8 +45,8 @@ class LoginController extends CommonController {
]); ]);
} }
$user = Auth::user(); $user = currentUserOrFail();
$tenant = app('tenant'); $tenant = currentTenant();
// Auf "lv" darf sich grundsätzlich jeder aktive Nutzer einloggen. // Auf "lv" darf sich grundsätzlich jeder aktive Nutzer einloggen.
// Auf Sub-Tenants gilt: // Auf Sub-Tenants gilt:
@@ -13,7 +13,7 @@ class ProfileController extends CommonController
return redirect()->intended('/login'); return redirect()->intended('/login');
} }
$user = auth()->user(); $user = currentUser();
$inertiaProvider = new InertiaProvider('UserManagement/Profile', [ $inertiaProvider = new InertiaProvider('UserManagement/Profile', [
'username' => $user->username, 'username' => $user->username,
@@ -26,8 +26,8 @@ class RegistrationController extends CommonController {
$inertiaProvider = new InertiaProvider('UserManagement/Registration', [ $inertiaProvider = new InertiaProvider('UserManagement/Registration', [
'errors' => $errors, 'errors' => $errors,
'appName' => app('tenant')->name, 'appName' => currentTenant()->name,
'tenant' => app('tenant'), 'tenant' => currentTenant(),
]); ]);
return $inertiaProvider->render(); return $inertiaProvider->render();
} }
@@ -46,7 +46,7 @@ class RegistrationController extends CommonController {
$userRoleMain = UserRole::USER_ROLE_USER; $userRoleMain = UserRole::USER_ROLE_USER;
$userRoleLocalGroup = UserRole::USER_ROLE_USER; $userRoleLocalGroup = UserRole::USER_ROLE_USER;
$localGroup = app('tenant')->slug === 'lv' ? $request->get('localGroup') : app('tenant')->slug; $localGroup = currentTenant()->slug === 'lv' ? $request->get('localGroup') : currentTenant()->slug;
$registrationRequest = new UserRegistrationRequest( $registrationRequest = new UserRegistrationRequest(
@@ -7,6 +7,7 @@ use App\Domains\UserManagement\Actions\UserChangePassword\UserChangePasswordRequ
use App\Scopes\CommonController; use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class StoreProfileController extends CommonController class StoreProfileController extends CommonController
{ {
@@ -27,11 +28,11 @@ class StoreProfileController extends CommonController
return response()->json(['success' => false, 'message' => 'Die Passwörter stimmen nicht überein.'], 422); return response()->json(['success' => false, 'message' => 'Die Passwörter stimmen nicht überein.'], 422);
} }
$actionRequest = new UserChangePasswordRequest(auth()->user(), $password); $actionRequest = new UserChangePasswordRequest(currentUserOrFail(), $password);
$command = new UserChangePasswordCommand($actionRequest); $command = new UserChangePasswordCommand($actionRequest);
$command->execute(); $command->execute();
auth()->logout(); Auth::logout();
return response()->json(['success' => true, 'message' => 'Dein Passwort wurde erfolgreich geändert.']); return response()->json(['success' => true, 'message' => 'Dein Passwort wurde erfolgreich geändert.']);
} }
} }
+46
View File
@@ -21,8 +21,54 @@ class InvoiceType extends CommonModel {
public const INVOICE_TYPE_MANAGEMENT = 'management'; public const INVOICE_TYPE_MANAGEMENT = 'management';
/**
* Erstattung eines Teilnahmebeitrags. Entsteht ausschließlich aus einem bestätigten
* Erstattungsvorgang und ist deshalb nicht von Hand wählbar ({@see self::selectable()}).
*/
public const INVOICE_TYPE_PARTICIPATION_REFUND = 'participation_refund';
protected $fillable = [ protected $fillable = [
'slug', 'slug',
'name', 'name',
'purchase_example',
'sort_order',
'selectable',
'counts_as_expense',
]; ];
protected $casts = [
'sort_order' => 'integer',
'selectable' => 'boolean',
'counts_as_expense' => 'boolean',
];
/**
* Die Typen, die in einem Formular zur Auswahl stehen dürfen -- nach Sortierung.
*
* Automatisch vergebene Typen bleiben außen vor: Ihre Abrechnungen entstehen aus einem Vorgang, der
* die Daten mitbringt; von Hand gewählt stünde ein leerer Rahmen ohne diesen Vorgang da.
*
* @return \Illuminate\Database\Eloquent\Collection<int, self>
*/
public static function selectable(): \Illuminate\Database\Eloquent\Collection
{
return self::where('selectable', true)->orderBy('sort_order')->get();
}
/**
* Die Typen, die in der Ausgabenrechnung einer Veranstaltung zählen -- nach Sortierung.
*
* Ausgenommen ist, was fachlich keine Ausgabe ist, sondern die Rücknahme einer Einnahme: Eine
* 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.
*
* 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>
*/
public static function countingAsExpense(): \Illuminate\Database\Eloquent\Collection
{
return self::where('counts_as_expense', true)->orderBy('sort_order')->orderBy('name')->get();
}
} }
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
/**
* Gründe, aus denen ein Teil des gezahlten Teilnahmebeitrags beim Verband bleibt.
*
* Gegenstück zu {@see RefundReason}: Jener sagt, warum erstattet wird, dieser, warum nicht alles.
*
* @property string $slug
* @property string $name
* @property string|null $document_text
* @property bool $requires_note
* @property int $sort_order
*/
class RetentionReason extends CommonModel
{
public const string CANCELLATION_FEE = 'cancellation_fee';
public const string INCURRED_COSTS = 'incurred_costs';
public const string MATERIAL = 'material';
public const string CUSTOM = 'custom';
protected $table = 'retention_reasons';
protected $primaryKey = 'slug';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'slug',
'name',
'document_text',
'requires_note',
'sort_order',
];
protected $casts = [
'requires_note' => 'boolean',
'sort_order' => 'integer',
];
/**
* Der Text, der auf dem Beleg unter „Einbehalten" steht. Bei einem Grund, der einen Freitext
* verlangt, ist es der Text der Aktionsleitung.
*/
public function documentText(?string $note = null): string
{
return $this->requires_note ? trim((string) $note) : (string) $this->document_text;
}
/**
* Optionen für das Frontend. `requiresNote` steuert dort das Freitextfeld.
*
* @return array<int, array{value: string, label: string, requiresNote: bool}>
*/
public static function options(): array
{
return self::orderBy('sort_order')->get()
->map(static fn (self $reason): array => [
'value' => $reason->slug,
'label' => $reason->name,
'requiresNote' => $reason->requires_note,
])
->all();
}
}
@@ -7,7 +7,7 @@ use App\Providers\InertiaProvider;
class TestRenderInertiaProvider class TestRenderInertiaProvider
{ {
public function index() { public function index() {
$inertiaProvider = new InertiaProvider('Invoice/CreateInvoice', ['appName' => app('tenant')->name]); $inertiaProvider = new InertiaProvider('Invoice/CreateInvoice', ['appName' => currentTenant()->name]);
return $inertiaProvider->render(); return $inertiaProvider->render();
} }
} }
+12 -4
View File
@@ -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 HalleLeipzig',
]); ]);
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]);
@@ -40,6 +40,7 @@ class RefundAcceptedMail extends Mailable
public function content(): Content public function content(): Content
{ {
$event = $this->participant->event()->first(); $event = $this->participant->event()->first();
$invoice = $this->refund->invoice()->first();
return new Content( return new Content(
view: 'emails.events.refund_accepted', view: 'emails.events.refund_accepted',
@@ -52,6 +53,18 @@ class RefundAcceptedMail extends Mailable
'accountOwner' => $this->refund->account_owner, 'accountOwner' => $this->refund->account_owner,
'accountIban' => Iban::format((string) $this->refund->account_iban), 'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null, 'hasDocument' => $this->pdfContent !== null,
'invoiceNumber' => $invoice?->invoice_number,
// Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist.
'hasRetention' => $this->refund->hasRetention(),
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
'retentionReason' => $this->refund->retentionReasonLabel(),
'retentionReasonNote' => $this->refund->retention_reason_note,
// Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts
// eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken.
'capturedByManagement' => $this->refund->wasCapturedByManagement(),
// Der Hinweis auf "Meine Abrechnungen" nur, wenn die Anmeldung an einem Konto hängt --
// die Seite filtert über die Nutzer-Verknüpfung und bliebe sonst leer.
'myInvoicesUrl' => $invoice?->user_id !== null ? url('/invoice/my-invoices/new') : null,
], ],
); );
} }
+1 -1
View File
@@ -10,7 +10,7 @@ class AdminRoleMiddleware
{ {
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if (!auth()->check()) { if (currentUser() === null) {
return redirect('/login')->with('message', 'Du musst eingeloggt sein.'); return redirect('/login')->with('message', 'Du musst eingeloggt sein.');
} }
+1 -1
View File
@@ -8,7 +8,7 @@ class LvOnlyMiddleware
{ {
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if (app('tenant')->slug !== 'lv') { if (currentTenant()->slug !== 'lv') {
return redirect('/admin')->with('message', 'Diese Funktion ist nur auf LV-Ebene verfügbar.'); return redirect('/admin')->with('message', 'Diese Funktion ist nur auf LV-Ebene verfügbar.');
} }
+1 -1
View File
@@ -15,7 +15,7 @@ class MainAdminRoleMiddleware
{ {
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if (!auth()->check()) { if (currentUser() === null) {
return redirect('/login')->with('message', 'Du musst eingeloggt sein.'); return redirect('/login')->with('message', 'Du musst eingeloggt sein.');
} }
+1 -1
View File
@@ -169,7 +169,7 @@ class Event extends InstancedModel
public function paymentMethods() : BelongsToMany { public function paymentMethods() : BelongsToMany {
// Pivot verknüpft über den Slug (payment_methods.slug), nicht über die numerische id // Pivot verknüpft über den Slug (payment_methods.slug), nicht über die numerische id
// von available_payment_methods -- die tenant-spezifische Instanz wird dadurch implizit // von available_payment_methods -- die tenant-spezifische Instanz wird dadurch implizit
// über den globalen SiteScope von AvailablePaymentMethod (gefiltert auf app('tenant')) aufgelöst. // über den globalen SiteScope von AvailablePaymentMethod (gefiltert auf currentTenant()) aufgelöst.
return $this->belongsToMany(AvailablePaymentMethod::class, 'event_payment_methods', 'event_id', 'slug', 'id', 'slug') return $this->belongsToMany(AvailablePaymentMethod::class, 'event_payment_methods', 'event_id', 'slug', 'id', 'slug')
->using(EventPaymentMethods::class) ->using(EventPaymentMethods::class)
->withPivot('configuration') ->withPivot('configuration')
+63
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Casts\AmountCast; use App\Casts\AmountCast;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Scopes\InstancedModel; use App\Scopes\InstancedModel;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -20,8 +21,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property Amount|null $amount * @property Amount|null $amount
* @property string|null $reason * @property string|null $reason
* @property string|null $reason_note * @property string|null $reason_note
* @property string|null $retention_reason
* @property string|null $retention_reason_note
* @property Amount|null $retained_amount
* @property string|null $account_owner * @property string|null $account_owner
* @property string|null $account_iban * @property string|null $account_iban
* @property int|null $captured_by
* @property int|null $invoice_id
* @property int|null $released_by * @property int|null $released_by
* @property \Illuminate\Support\Carbon|null $released_at * @property \Illuminate\Support\Carbon|null $released_at
* @property \Illuminate\Support\Carbon|null $accepted_at * @property \Illuminate\Support\Carbon|null $accepted_at
@@ -49,8 +55,13 @@ class ParticipantRefund extends InstancedModel
'amount', 'amount',
'reason', 'reason',
'reason_note', 'reason_note',
'retention_reason',
'retention_reason_note',
'retained_amount',
'account_owner', 'account_owner',
'account_iban', 'account_iban',
'captured_by',
'invoice_id',
'released_by', 'released_by',
'released_at', 'released_at',
'accepted_at', 'accepted_at',
@@ -59,6 +70,7 @@ class ParticipantRefund extends InstancedModel
protected $casts = [ protected $casts = [
'amount' => AmountCast::class, 'amount' => AmountCast::class,
'retained_amount' => AmountCast::class,
'released_at' => 'datetime', 'released_at' => 'datetime',
'accepted_at' => 'datetime', 'accepted_at' => 'datetime',
'cancelled_at' => 'datetime', 'cancelled_at' => 'datetime',
@@ -82,6 +94,29 @@ class ParticipantRefund extends InstancedModel
return $this->belongsTo(RefundReason::class, 'reason', 'slug'); return $this->belongsTo(RefundReason::class, 'reason', 'slug');
} }
/**
* Die Abrechnung, die aus diesem Vorgang entstanden ist. Der Auszahlungsstand steht dort und wird
* hier nicht gedoppelt.
*/
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
/**
* Wer die Bankverbindung aufgenommen hat -- leer, wenn der Teili sie selbst eingetragen hat.
*/
public function capturedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'captured_by');
}
/** Ob die Angaben von der Aktionsleitung stammen und nicht vom Teili selbst. */
public function wasCapturedByManagement(): bool
{
return $this->captured_by !== null;
}
public function isPending(): bool public function isPending(): bool
{ {
return $this->status === self::STATUS_PENDING; return $this->status === self::STATUS_PENDING;
@@ -102,4 +137,32 @@ class ParticipantRefund extends InstancedModel
{ {
return (string) ($this->reasonRelation()->first()?->name ?? ''); return (string) ($this->reasonRelation()->first()?->name ?? '');
} }
/** Der Einbehaltungsgrund als Stammdatensatz -- leer, wenn voll erstattet wurde. */
public function retentionReasonRelation(): BelongsTo
{
return $this->belongsTo(RetentionReason::class, 'retention_reason', 'slug');
}
public function retentionReasonLabel(): string
{
return (string) ($this->retentionReasonRelation()->first()?->name ?? '');
}
/** Der auf dem Beleg auszuweisende Text zur Einbehaltung. */
public function retentionReasonText(): string
{
return $this->retentionReasonRelation()->first()?->documentText($this->retention_reason_note) ?? '';
}
/**
* Ob etwas beim Verband bleibt -- die halbe Cent-Toleranz fängt die Float-Rundung ab.
*
* Der Betrag steht in `retained_amount` und wird beim Einreichen festgeschrieben. Ihn zur Laufzeit
* aus `amount_paid` zu rechnen ginge schief: Danach führt das Feld bereits den Rest.
*/
public function hasRetention(): bool
{
return ($this->retained_amount?->getAmount() ?? 0.0) > 0.005;
}
} }
+1 -1
View File
@@ -50,7 +50,7 @@ class Tenant extends CommonModel
} }
public static function getTempDirectory() : string { public static function getTempDirectory() : string {
return app('tenant')->slug . '/temp-data/'; return currentTenant()->slug . '/temp-data/';
} }
/** /**
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -115,10 +115,10 @@ class User extends Authenticatable
} }
public function localGroup() : Tenant { public function localGroup() : Tenant {
return $this->belongsTo(Tenant::class, 'local_group', 'slug')->first(); return $this->belongsTo(Tenant::class, 'local_group', 'slug')->firstOrFail();
} }
public function costUnits() public function costUnits() : BelongsToMany
{ {
return $this->belongsToMany(CostUnit::class, 'cost_unit_treasurers', 'user_id', 'cost_unit_id') return $this->belongsToMany(CostUnit::class, 'cost_unit_treasurers', 'user_id', 'cost_unit_id')
->withTimestamps(); ->withTimestamps();
+6 -6
View File
@@ -7,12 +7,12 @@ use App\Models\User;
class AuthCheckProvider { class AuthCheckProvider {
public function checkLoggedIn() : bool { public function checkLoggedIn() : bool {
if (!auth()->check()) { $user = currentUser();
if ($user === null) {
return false; return false;
} }
$user = auth()->user(); $tenant = currentTenant();
$tenant = app('tenant');
if ($tenant->slug === 'lv') { if ($tenant->slug === 'lv') {
return $user->active; return $user->active;
} }
@@ -29,9 +29,9 @@ class AuthCheckProvider {
return null; return null;
} }
$user = auth()->user(); $user = currentUserOrFail();
if (app('tenant')->slug === 'lv') { if (currentTenant()->slug === 'lv') {
return $user->user_role_main; return $user->user_role_main;
} }
@@ -50,7 +50,7 @@ class AuthCheckProvider {
* Diese Rolle hebt das lokale Rechtesystem für alle Sub-Tenants auf. * Diese Rolle hebt das lokale Rechtesystem für alle Sub-Tenants auf.
*/ */
public function isMainAdministrator(?User $user = null) : bool { public function isMainAdministrator(?User $user = null) : bool {
$user ??= auth()->user(); $user ??= currentUser();
return $user !== null return $user !== null
&& $user->user_role_main === UserRole::USER_ROLE_ADMIN; && $user->user_role_main === UserRole::USER_ROLE_ADMIN;
+2 -2
View File
@@ -61,7 +61,7 @@ class CronTaskHandleProvider extends CommonController
private function runTask(CronTask $task) private function runTask(CronTask $task)
{ {
$logger = $this->taskLogger($task->name, app('tenant')); $logger = $this->taskLogger($task->name, currentTenant());
app()->instance('taskLogger', $logger); app()->instance('taskLogger', $logger);
$taskClass = "\\App\\Tasks\\" . $task->name; $taskClass = "\\App\\Tasks\\" . $task->name;
@@ -73,7 +73,7 @@ class CronTaskHandleProvider extends CommonController
$lastRun = []; $lastRun = [];
} }
$lastRun[app('tenant')->slug] = now(); $lastRun[currentTenant()->slug] = now();
// Update last_run // Update last_run
$task->last_run = $lastRun; $task->last_run = $lastRun;
$task->save(); $task->save();
+23 -7
View File
@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit; use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType; use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\Tenant; use App\Models\Tenant;
@@ -21,7 +22,7 @@ class GlobalDataProvider {
private ?User $user; private ?User $user;
public function __invoke() { public function __invoke() {
$this->user = auth()->user(); $this->user = currentUser();
$canAccessAdmin = false; $canAccessAdmin = false;
$isMainAdmin = false; $isMainAdmin = false;
@@ -37,7 +38,7 @@ class GlobalDataProvider {
return response()->json([ return response()->json([
'user' => null !== $this->user ? new UserResource($this->user)->toArray(request()) : null, 'user' => null !== $this->user ? new UserResource($this->user)->toArray(request()) : null,
'navbar' => $this->generateNavbar(), 'navbar' => $this->generateNavbar(),
'tenant' => app('tenant'), 'tenant' => currentTenant(),
'activeUsers' => $this->getActiveUsers(), 'activeUsers' => $this->getActiveUsers(),
'version' => config('app.version'), 'version' => config('app.version'),
'currentEvent' => $this->getCurrentEventData(), 'currentEvent' => $this->getCurrentEventData(),
@@ -46,9 +47,10 @@ class GlobalDataProvider {
]); ]);
} }
/** Die Typen für die Neuanlage durch Nutzer*innen. Reisekosten haben ein eigenes Formular. */
public function getInvoiceTypes() : JsonResponse { public function getInvoiceTypes() : JsonResponse {
$invoiceTypes = []; $invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) { foreach (InvoiceType::selectable() as $invoiceType) {
if ( if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING $invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING
) { ) {
@@ -57,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,
]; ];
} }
@@ -110,9 +115,15 @@ class GlobalDataProvider {
]; ];
} }
/**
* Die Typen zum Umbuchen durch die Kassenwart*innen -- „Sonstige Kosten" ans Ende.
*
* Auch hier nur wählbare Typen: sonst ließe sich eine beliebige Abrechnung nachträglich zu einer
* Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
*/
public function getAllInvoiceTypes() : JsonResponse { public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = []; $invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) { foreach (InvoiceType::selectable() as $invoiceType) {
if ( if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER $invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
) { ) {
@@ -178,8 +189,8 @@ class GlobalDataProvider {
$activeUsers = []; $activeUsers = [];
$conditions = ['active' => true]; $conditions = ['active' => true];
if (app('tenant')->slug !== 'lv') { if (currentTenant()->slug !== 'lv') {
$conditions['local_group'] = app('tenant')->slug; $conditions['local_group'] = currentTenant()->slug;
} }
foreach ($rawUsers = User::where($conditions)->get() as $user) { foreach ($rawUsers = User::where($conditions)->get() as $user) {
@@ -195,6 +206,11 @@ class GlobalDataProvider {
return response()->json(RefundReason::options()); return response()->json(RefundReason::options());
} }
/** Auswahl der Gründe, aus denen ein Teil des Beitrags beim Verband bleibt. */
public function getRetentionReasons() : JsonResponse {
return response()->json(RetentionReason::options());
}
public function getEventSettingData(Request $request) : JsonResponse { public function getEventSettingData(Request $request) : JsonResponse {
return response()->json( return response()->json(
[ [
+1 -1
View File
@@ -16,7 +16,7 @@ final class InertiaProvider
private ?User $user; private ?User $user;
public function __construct(string $vueFile, array $props) { public function __construct(string $vueFile, array $props) {
$this->user = auth()->user(); $this->user = currentUser();
$this->vueFile = $vueFile; $this->vueFile = $vueFile;
$this->props = $props; $this->props = $props;
} }
+2 -2
View File
@@ -20,7 +20,7 @@ class TenantUserProvider extends EloquentUserProvider
} }
// Auf "lv" gilt grundsätzlich keine local_group-Einschränkung. // Auf "lv" gilt grundsätzlich keine local_group-Einschränkung.
if (app('tenant')->slug === 'lv') { if (currentTenant()->slug === 'lv') {
return $query->first(); return $query->first();
} }
@@ -30,7 +30,7 @@ class TenantUserProvider extends EloquentUserProvider
// -> "Bundesrecht steht über Landesrecht": Login überall möglich. // -> "Bundesrecht steht über Landesrecht": Login überall möglich.
$query->where('active', true) $query->where('active', true)
->where(function ($q) { ->where(function ($q) {
$q->where('local_group', app('tenant')->slug) $q->where('local_group', currentTenant()->slug)
->orWhere('user_role_main', UserRole::USER_ROLE_ADMIN); ->orWhere('user_role_main', UserRole::USER_ROLE_ADMIN);
}); });
+13 -5
View File
@@ -16,13 +16,21 @@ class UploadFileProvider {
$this->costUnit = $costUnit; $this->costUnit = $costUnit;
} }
/**
* Das Ablageverzeichnis der Belege einer Kostenstelle, relativ zur Disk `local`
* (Wurzel `storage/app/private`).
*
* Öffentlich, weil Belege nicht nur aus einem Upload entstehen: Eine Beitragserstattung erzeugt ihren
* Eigenbeleg im Speicher und legt ihn über den FileWriteProvider ab -- landen soll er trotzdem dort,
* wo alle anderen Belege liegen.
*/
public static function directoryFor(CostUnit $costUnit) : string {
return sprintf('%1$s/invoices/%2$s', currentTenant()->slug, $costUnit->id);
}
public function saveUploadedFile() : ?InvoiceFile { public function saveUploadedFile() : ?InvoiceFile {
try { try {
$directory = sprintf( $directory = self::directoryFor($this->costUnit);
'%1$s/invoices/%2$s',
app('tenant')->slug,
$this->costUnit->id
);
$filename = $this->normalizeFilename($this->file->getClientOriginalName()); $filename = $this->normalizeFilename($this->file->getClientOriginalName());
+51 -5
View File
@@ -2,11 +2,13 @@
namespace App\Repositories; namespace App\Repositories;
use \currentUser;
use App\Enumerations\CostUnitType; use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus; 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;
@@ -66,8 +68,7 @@ class CostUnitRepository {
} }
public function getCostUnitsByCriteria(array $criteria, bool $forDisplay = true, $disableAccessCheck = false) : array { public function getCostUnitsByCriteria(array $criteria, bool $forDisplay = true, $disableAccessCheck = false) : array {
$user = Auth()->user(); $user = currentUser();
if ($disableAccessCheck) { if ($disableAccessCheck) {
$canSeeAll = true; $canSeeAll = true;
} else { } else {
@@ -96,7 +97,7 @@ class CostUnitRepository {
public function listForSummary(int $maxCountCostUnits) : array { public function listForSummary(int $maxCountCostUnits) : array {
$costUnits = $this->getCostUnitsByCriteria([ $costUnits = $this->getCostUnitsByCriteria([
'archived' => false, 'archived' => false,
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
],false); ],false);
foreach ($costUnits as &$cu) { foreach ($costUnits as &$cu) {
@@ -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) {
@@ -297,7 +297,7 @@ class EventParticipantRepository {
public function getMyParticipations(?int $maxEvents = null) : array { public function getMyParticipations(?int $maxEvents = null) : array {
$participations = []; $participations = [];
$user = auth()->user(); $user = currentUser();
if ($user === null) { if ($user === null) {
return $participations; return $participations;
} }
@@ -326,13 +326,13 @@ class EventParticipantRepository {
} }
public function getMyParticipationByIdentifier(string $identifier) : ?EventParticipant { public function getMyParticipationByIdentifier(string $identifier) : ?EventParticipant {
$user = auth()->user(); $user = currentUser();
if ($user === null) { if ($user === null) {
return null; return null;
} }
return EventParticipant::where('identifier', $identifier) return EventParticipant::where('identifier', $identifier)
->where('tenant', app('tenant')->slug) ->where('tenant', currentTenant()->slug)
->where('user_id', $user->id) ->where('user_id', $user->id)
->whereNull('unregistered_at') ->whereNull('unregistered_at')
->first(); ->first();
+5 -5
View File
@@ -20,7 +20,7 @@ class InvoiceRepository {
]; ];
$user = auth()->user(); $user = currentUser();
if (null === $user) { if (null === $user) {
return $invoices; return $invoices;
} }
@@ -41,7 +41,7 @@ class InvoiceRepository {
} }
public function getUnexportedInvoices() : Collection { public function getUnexportedInvoices() : Collection {
return Invoice::where(['tenant' => app('tenant')->slug, 'status' => InvoiceStatus::INVOICE_STATUS_EXPORTED, 'upload_required' => true])->get(); return Invoice::where(['tenant' => currentTenant()->slug, 'status' => InvoiceStatus::INVOICE_STATUS_EXPORTED, 'upload_required' => true])->get();
} }
public function getByStatus(CostUnit $costUnit, string $status, bool $forDisplay = true) : array { public function getByStatus(CostUnit $costUnit, string $status, bool $forDisplay = true) : array {
@@ -63,8 +63,8 @@ class InvoiceRepository {
foreach (Invoice::where( foreach (Invoice::where(
[ [
'status' => $status, 'status' => $status,
'user_id' => auth()->user()->id, 'user_id' => currentUserOrFail()->id,
'tenant' => app('tenant')->slug, 'tenant' => currentTenant()->slug,
] ]
)->get() as $invoice) { )->get() as $invoice) {
@@ -79,7 +79,7 @@ class InvoiceRepository {
return null; return null;
} }
$isTreasurer = $invoice->costUnit()->first()->treasurers()->where('user_id', auth()->user()->id)->exists(); $isTreasurer = $invoice->costUnit()->first()->treasurers()->where('user_id', currentUserOrFail()->id)->exists();
if ($isTreasurer) { if ($isTreasurer) {
return $invoice; return $invoice;
} }
+2 -2
View File
@@ -20,7 +20,7 @@ class UserRepository {
} }
public function getCurrentUserDetails() : array { public function getCurrentUserDetails() : array {
$user = auth()->user(); $user = currentUser();
$return = [ $return = [
'userId' => null, 'userId' => null,
@@ -31,7 +31,7 @@ class UserRepository {
'userAccountIban' => '', 'userAccountIban' => '',
]; ];
if (null !== auth()->user()) { if ($user !== null) {
$return = [ $return = [
'userId' => $user->id, 'userId' => $user->id,
'userName' => trim($user->getOfficialName()), 'userName' => trim($user->getOfficialName()),
+4 -1
View File
@@ -32,7 +32,10 @@ class CostUnitResource {
$amounts = []; $amounts = [];
$overAllAmount = new Amount(0, 'Euro'); $overAllAmount = new Amount(0, 'Euro');
$overAllEstimatedAmount = new Amount(0, 'Euro'); $overAllEstimatedAmount = new Amount(0, 'Euro');
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) { // Nur echte Aufwandsarten: Eine Beitragserstattung ist die Rücknahme einer Einnahme und wird auf
// der Einnahmenseite bereits berücksichtigt -- hier gezählt, stünde sie ein zweites Mal in der
// Bilanz. `totalAmount` weiter oben bleibt davon unberührt, das ist die Kassensicht.
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
$overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)); $overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType));
$overAllEstimatedAmount->addAmount($costUnitRepository->sumupEstimatedByInvoiceType($this->costUnit, $invoiceType)); $overAllEstimatedAmount->addAmount($costUnitRepository->sumupEstimatedByInvoiceType($this->costUnit, $invoiceType));
$amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString(); $amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString();
+39 -2
View File
@@ -27,8 +27,8 @@ class EventResource extends JsonResource{
'id' => $this->event->id, 'id' => $this->event->id,
'name' => $this->event->name, 'name' => $this->event->name,
'identifier' => $this->event->identifier, 'identifier' => $this->event->identifier,
'url' => 'https://' . app('tenant')->url . '/event/' . $this->event->identifier . '/signup', 'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup',
'urlShort' => 'https://' . app('tenant')->url . '/event/' . $this->event->identifier, 'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier,
'location' => $this->event->location, 'location' => $this->event->location,
'postalCode' => $this->event->postal_code, 'postalCode' => $this->event->postal_code,
'email' => $this->event->email, 'email' => $this->event->email,
@@ -96,6 +96,14 @@ class EventResource extends JsonResource{
$returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']); $returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']);
// Eigene Zeile in der Übersicht: In den Zeilen je Teilnahmeart hätte der Betrag nichts zu suchen,
// dort stehen nur aktive Anmeldungen.
$retainedFromUnregistered = $this->sumPaidOfUnregistered();
$returnArray['retainedFromUnregistered'] = [
'value' => $retainedFromUnregistered->getAmount(),
'readable' => $retainedFromUnregistered->toString(),
];
$totalBalanceReal = new Amount(0, 'Euro'); $totalBalanceReal = new Amount(0, 'Euro');
$totalBalanceExpected = new Amount(0, 'Euro'); $totalBalanceExpected = new Amount(0, 'Euro');
@@ -272,6 +280,13 @@ class EventResource extends JsonResource{
$realAmount->addAmount(new Amount($participantData['amount']['paid']['value'], 'Euro')); $realAmount->addAmount(new Amount($participantData['amount']['paid']['value'], 'Euro'));
} }
// Was abgemeldete Teilis gezahlt haben und nicht zurückbekommen, gehört in beide Spalten: Das
// Geld liegt beim Verband (real) und fließt nicht mehr ab (erwartet). Ohne diese Zeile stünde
// jede Veranstaltung mit Abmeldungen dauerhaft schlechter da, als sie ist.
$retained = $this->sumPaidOfUnregistered();
$realAmount->addAmount($retained);
$expectedAmount->addAmount($retained);
return ['real' => [ return ['real' => [
'amount' => $realAmount, 'amount' => $realAmount,
'readable' => $realAmount->toString() 'readable' => $realAmount->toString()
@@ -283,6 +298,28 @@ class EventResource extends JsonResource{
]; ];
} }
/**
* Was von abgemeldeten Teilis beim Verband geblieben ist.
*
* `amount_paid` führt nach einer Erstattung genau den einbehaltenen Rest; wurde nie erstattet, steht
* dort der volle gezahlte Beitrag. Beides ist Geld, das der Veranstaltung zusteht.
*
* Bewusst eine direkte Abfrage wie in {@see self::getParticipants()} nebenan -- ein einzelner
* Repository-Aufruf zwischen den Inline-Queries dieser Klasse würde sie uneinheitlicher machen.
*/
public function sumPaidOfUnregistered() : Amount
{
$sum = new Amount(0, 'Euro');
foreach ($this->event->participants()->whereNotNull('unregistered_at')->get() as $participant) {
if ($participant->amount_paid !== null) {
$sum->addAmount($participant->amount_paid);
}
}
return $sum;
}
public function getParticipants(string $participationType) : array { public function getParticipants(string $participationType) : array {
$returnData = []; $returnData = [];
$returnData['amount'] = [ $returnData['amount'] = [
@@ -29,9 +29,17 @@ class ParticipantRefundResource extends JsonResource
'reason' => $this->resource->reason, 'reason' => $this->resource->reason,
'reasonLabel' => $this->resource->reasonLabel(), 'reasonLabel' => $this->resource->reasonLabel(),
'reasonNote' => $this->resource->reason_note, 'reasonNote' => $this->resource->reason_note,
// Was beim Verband bleibt. `hasRetention` erspart dem Frontend den Betragsvergleich samt
// Rundungsfrage -- es soll nur entscheiden, ob der Hinweis angezeigt wird.
'hasRetention' => $this->resource->hasRetention(),
'retainedAmount' => $this->resource->retained_amount?->toString() ?? '0,00 Euro',
'retentionReasonLabel' => $this->resource->retentionReasonLabel(),
'retentionReasonNote' => $this->resource->retention_reason_note,
'releasedAt' => $this->resource->released_at?->format('d.m.Y'), 'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'), 'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'), 'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
]; ];
} }
} }
+1 -1
View File
@@ -31,7 +31,7 @@ abstract class CommonController {
protected AdminTenantRepository $adminTenants; protected AdminTenantRepository $adminTenants;
public function __construct() { public function __construct() {
$this->tenant = app('tenant'); $this->tenant = currentTenant();
$this->users = new UserRepository(); $this->users = new UserRepository();
$this->costUnits = new CostUnitRepository(); $this->costUnits = new CostUnitRepository();
$this->pageTexts = new PageTextRepository(); $this->pageTexts = new PageTextRepository();
+1 -1
View File
@@ -9,6 +9,6 @@ class SiteScope implements Scope
{ {
public function apply(Builder $builder, Model $model): void public function apply(Builder $builder, Model $model): void
{ {
$builder->where($model->getTable() . '.tenant', app('tenant')->slug); $builder->where($model->getTable() . '.tenant', currentTenant()->slug);
} }
} }
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\Auth;
if (!function_exists('currentUser')) {
/**
* Der aktuell eingeloggte Nutzer, oder null wenn niemand eingeloggt ist.
*/
function currentUser() : ?User {
return Auth::user();
}
}
if (!function_exists('currentUserOrFail')) {
/**
* Der aktuell eingeloggte Nutzer. Für Code hinter der auth-Middleware,
* wo ein fehlender Nutzer ein Programmierfehler ist.
*
* @throws AuthenticationException
*/
function currentUserOrFail() : User {
return Auth::user() ?? throw new AuthenticationException();
}
}
if (!function_exists('currentTenant')) {
/**
* Der über IdentifyTenant aufgelöste Tenant der aktuellen Anfrage.
*/
function currentTenant() : Tenant {
return app('tenant');
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ use Psr\Log\LoggerInterface;
class UploadInvoices implements CronTask { class UploadInvoices implements CronTask {
public function handle(): void public function handle(): void
{ {
if (!app('tenant')->upload_exports) { if (!currentTenant()->upload_exports) {
return; return;
} }
+4
View File
@@ -20,6 +20,7 @@
"maennchen/zipstream-php": "^3.1" "maennchen/zipstream-php": "^3.1"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-ide-helper": "^3.7",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
@@ -29,6 +30,9 @@
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {
"files": [
"app/Support/helpers.php"
],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
"Database\\Factories\\": "database/factories/", "Database\\Factories\\": "database/factories/",
Generated
+293 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9fb8095137a8e9d664e44b9fca5f1f5a", "content-hash": "6e38fe16a000f12f5fff089b08b49780",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -6934,6 +6934,298 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "barryvdh/laravel-ide-helper",
"version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-ide-helper.git",
"reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a",
"reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a",
"shasum": ""
},
"require": {
"barryvdh/reflection-docblock": "^2.4",
"composer/class-map-generator": "^1.0",
"ext-json": "*",
"illuminate/console": "^11.15 || ^12 || ^13.0",
"illuminate/database": "^11.15 || ^12 || ^13.0",
"illuminate/filesystem": "^11.15 || ^12 || ^13.0",
"illuminate/support": "^11.15 || ^12 || ^13.0",
"php": "^8.2"
},
"require-dev": {
"ext-pdo_sqlite": "*",
"friendsofphp/php-cs-fixer": "^3",
"illuminate/config": "^11.15 || ^12 || ^13.0",
"illuminate/view": "^11.15 || ^12 || ^13.0",
"larastan/larastan": "^3.1",
"mockery/mockery": "^1.4",
"orchestra/testbench": "^9.2 || ^10 || ^11.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.12",
"spatie/phpunit-snapshot-assertions": "^4 || ^5",
"vlucas/phpdotenv": "^5"
},
"suggest": {
"illuminate/events": "Required for automatic helper generation (^6|^7|^8|^9|^10|^11)."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\LaravelIdeHelper\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.",
"keywords": [
"autocomplete",
"codeintel",
"dev",
"helper",
"ide",
"laravel",
"netbeans",
"phpdoc",
"phpstorm",
"sublime"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-ide-helper/issues",
"source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.7.0"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-03-17T14:12:51+00:00"
},
{
"name": "barryvdh/reflection-docblock",
"version": "v2.4.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/ReflectionDocBlock.git",
"reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b",
"reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^8.5.14|^9"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.3.x-dev"
}
},
"autoload": {
"psr-0": {
"Barryvdh": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"support": {
"source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.4.1"
},
"time": "2026-03-05T20:09:01+00:00"
},
{
"name": "composer/class-map-generator",
"version": "1.7.3",
"source": {
"type": "git",
"url": "https://github.com/composer/class-map-generator.git",
"reference": "86d8208fc3c649a3a999daf1a63c25201be2990f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f",
"reference": "86d8208fc3c649a3a999daf1a63c25201be2990f",
"shasum": ""
},
"require": {
"composer/pcre": "^2.1 || ^3.1",
"php": "^7.2 || ^8.0",
"symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
"phpstan/phpstan-phpunit": "^1 || ^2",
"phpstan/phpstan-strict-rules": "^1.1 || ^2",
"phpunit/phpunit": "^8",
"symfony/filesystem": "^5.4 || ^6 || ^7 || ^8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\ClassMapGenerator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Utilities to scan PHP code and generate class maps.",
"keywords": [
"classmap"
],
"support": {
"issues": "https://github.com/composer/class-map-generator/issues",
"source": "https://github.com/composer/class-map-generator/tree/1.7.3"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-05-05T09:17:07+00:00"
},
{
"name": "composer/pcre",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-06-07T11:47:49+00:00"
},
{ {
"name": "fakerphp/faker", "name": "fakerphp/faker",
"version": "v1.24.1", "version": "v1.24.1",
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Auslagentyp für automatisch eingereichte Beitragserstattungen.
*
* `invoices.type` ist ein Fremdschlüssel auf `invoice_types.slug` -- ohne diese Zeile scheitert schon das
* Anlegen der Abrechnung. Da `invoice_types` im Repository nirgends geseedet wird, ist die Migration der
* einzige verlässliche Weg; sie schreibt nur, was noch fehlt.
*
* Zur neuen Spalte `selectable`: Der Typ entsteht ausschließlich aus einem Erstattungsvorgang und darf in
* keinem Formular zur Auswahl stehen. Bisher wurden solche Ausnahmen als hartkodierte `continue`-Zweige
* in GlobalDataProvider gelöst (dort für `travelling`) -- eine dritte Sonderregel an zwei Stellen wäre die
* Sorte Code, die beim nächsten Typ wieder wächst. Das Flag sagt selbst, was es tut.
*/
return new class extends Migration {
private const string SLUG = 'participation_refund';
public function up(): void
{
Schema::table('invoice_types', function (Blueprint $table) {
$table->boolean('selectable')->default(true)->after('sort_order');
});
if (DB::table('invoice_types')->where('slug', self::SLUG)->exists()) {
return;
}
DB::table('invoice_types')->insert([
'slug' => self::SLUG,
'name' => 'Beitragserstattung',
// Hinter "Verwaltung" (2) und vor "Sonstige Kosten" (3) wäre die Reihenfolge unklar; der Typ
// taucht ohnehin nur in Summen und Auswertungen auf.
'sort_order' => 4,
'selectable' => false,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
// Nur löschen, wenn keine Abrechnung daran hängt -- der Fremdschlüssel würde es sonst verhindern,
// und ein Rollback soll nicht an fremden Daten scheitern.
if (!DB::table('invoices')->where('type', self::SLUG)->exists()) {
DB::table('invoice_types')->where('slug', self::SLUG)->delete();
}
Schema::table('invoice_types', function (Blueprint $table) {
$table->dropColumn('selectable');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Verbindet den Erstattungsvorgang mit der Abrechnung, die aus ihm entstanden ist.
*
* Damit liest die Aktionsleitung den Auszahlungsstand dort ab, wo er entsteht -- in der Abrechnung --,
* statt ihn am Vorgang zu doppeln und synchron halten zu müssen.
*
* `nullOnDelete`: wird eine Abrechnung gelöscht, bleibt der Erstattungsvorgang bestehen. Er ist die
* Erklärung des Teilis und hat einen eigenen Beleg; er hängt nicht am Leben der Abrechnung.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->foreignId('invoice_id')->nullable()->after('account_iban')
->constrained('invoices', 'id')->nullOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->dropForeign(['invoice_id']);
$table->dropColumn('invoice_id');
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Hält fest, wer die Bankverbindung erfasst hat.
*
* `null` heißt: der Teili hat sie selbst über den Token-Link eingetragen und dabei die Erklärung
* angekreuzt. Ist die Spalte gesetzt, hat die Aktionsleitung die Angaben aufgenommen, weil sie ihr schon
* vorlagen -- der Beleg weist das dann samt Namen aus, damit niemand die Erklärung für eine Bestätigung
* des Teilis hält.
*
* Kein zusätzliches `captured_at`: Der Zeitpunkt ist `accepted_at`, der in diesem Fall mit der Freigabe
* zusammenfällt.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->foreignId('captured_by')->nullable()->after('account_iban')
->constrained('users', 'id')->nullOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->dropForeign(['captured_by']);
$table->dropColumn('captured_by');
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Trennt die Abrechnungstypen, die in der Ausgabenrechnung einer Veranstaltung zählen, von denen, die es
* nicht tun.
*
* Anlass ist die Beitragserstattung: Sie ist keine Aufwandsposition, sondern die Rücknahme einer
* Einnahme. Die Einnahmenseite berücksichtigt sie bereits -- ein abgemeldeter Teili fällt aus
* `EventResource::getParticipants()` heraus, sein gezahlter Beitrag verschwindet also dort schon. Als
* Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
*
* Betroffen ist nur die Budgetrechnung der Veranstaltung. In der Kassensicht (Kostenstellen-Liste,
* Dashboard) bleibt der Betrag stehen -- dort fließt er tatsächlich ab.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('invoice_types', function (Blueprint $table) {
$table->boolean('counts_as_expense')->default(true)->after('selectable');
});
DB::table('invoice_types')
->where('slug', 'participation_refund')
->update(['counts_as_expense' => false]);
}
public function down(): void
{
Schema::table('invoice_types', function (Blueprint $table) {
$table->dropColumn('counts_as_expense');
});
}
};
@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gründe, aus denen ein Teil des gezahlten Beitrags beim Verband bleibt.
*
* Wird nicht der volle Beitrag erstattet, ist der Rest eine Einnahme, die begründet sein muss -- in der
* Buchhaltung ist ein einbehaltener Betrag ohne Grund nicht haltbar. Aufgebaut wie
* {@see \App\Enumerations\RefundReason}: app-weite Stammdaten, `slug` als Schlüssel.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('retention_reasons', function (Blueprint $table) {
$table->string('slug')->primary();
$table->string('name');
$table->text('document_text')->nullable();
$table->boolean('requires_note')->default(false);
$table->integer('sort_order')->default(0);
$table->timestamps();
});
DB::table('retention_reasons')->insert([
[
'slug' => 'cancellation_fee',
'name' => 'Stornogebühr laut Ausschreibung',
'document_text' => 'Einbehalten wurde die in der Ausschreibung genannte Stornogebühr.',
'requires_note' => false,
'sort_order' => 10,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'incurred_costs',
'name' => 'Bereits entstandene Kosten',
'document_text' => 'Einbehalten wurden Kosten, die zum Zeitpunkt der Abmeldung bereits entstanden waren.',
'requires_note' => false,
'sort_order' => 20,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'material',
'name' => 'Bereits beschafftes Material',
'document_text' => 'Einbehalten wurden Kosten für Material, das bereits beschafft wurde.',
'requires_note' => false,
'sort_order' => 30,
'created_at' => now(),
'updated_at' => now(),
],
[
// Der Text entsteht erst aus dem Freitext der Aktionsleitung -- deshalb hier leer.
'slug' => 'custom',
'name' => 'Sonstiger Grund',
'document_text' => null,
'requires_note' => true,
'sort_order' => 40,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('retention_reasons');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Was beim Verband bleibt und warum.
*
* Nur belegt, wenn weniger erstattet wird als gezahlt wurde. Bei voller Erstattung bleiben die Felder
* leer bzw. auf 0 -- dann gibt es nichts zu begründen.
*
* `retained_amount` wird beim Einreichen festgeschrieben und nicht zur Laufzeit gerechnet: Danach führt
* `event_participants.amount_paid` bereits den Rest, eine Differenz daraus wäre ab dem Moment falsch.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->string('retention_reason')->nullable()->after('reason_note');
$table->text('retention_reason_note')->nullable()->after('retention_reason');
$table->float('retained_amount', 2)->default(0)->after('retention_reason_note');
});
// Der Fremdschlüssel in einem eigenen Aufruf: zusammen mit dem Anlegen der Spalte hat MariaDB ihn
// hier stillschweigend übergangen, und ein `down()` lief anschließend ins Leere.
Schema::table('participant_refunds', function (Blueprint $table) {
$table->foreign('retention_reason')->references('slug')->on('retention_reasons')
->restrictOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::table('participant_refunds', function (Blueprint $table) {
$table->dropForeign(['retention_reason']);
$table->dropColumn(['retention_reason', 'retention_reason_note', 'retained_amount']);
});
}
};
@@ -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 HalleLeipzig',
'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');
});
}
};
@@ -84,6 +84,9 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
/* Die Versicherung des Teilis unter den Angaben */ /* Die Versicherung des Teilis unter den Angaben */
.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; } .declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; }
/* Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat -- steht sonst nicht auf dem Beleg. */
.capture-note { font-size: 8pt; color: #555; line-height: 1.55; margin-top: 3mm; }
/* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */ /* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */
.edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()), .edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()),
('participant_refund', 'header_sender_return', '<div class="absender-rueck">{sender_name}{if:sender_address_1} &middot; {sender_address_1}{/if:sender_address_1} &middot; {sender_postcode} {sender_city}</div>', 30, 1, NOW(), NOW()), ('participant_refund', 'header_sender_return', '<div class="absender-rueck">{sender_name}{if:sender_address_1} &middot; {sender_address_1}{/if:sender_address_1} &middot; {sender_postcode} {sender_city}</div>', 30, 1, NOW(), NOW()),
@@ -130,5 +133,6 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
{details_table} {details_table}
<div class="declaration">{declaration_text}</div>', 90, 1, NOW(), NOW()), <div class="declaration">{declaration_text}</div>
{if:capture_note}<div class="capture-note">{capture_note}</div>{/if:capture_note}', 90, 1, NOW(), NOW()),
('participant_refund', 'footer', '', 100, 1, NOW(), NOW()); ('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
@@ -2,10 +2,22 @@
<html> <html>
<body> <body>
<h1>Hallo {{$name}}!</h1> <h1>Hallo {{$name}}!</h1>
<p> @if ($capturedByManagement)
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen <p>
uns vor. die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
</p> Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die
Erstattung ist bereits als Abrechnung eingereicht und wird nun bearbeitet.
</p>
<p>
<strong>Bitte prüfe die unten stehenden Angaben</strong> &ndash; besonders die IBAN.
</p>
@else
<p>
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
uns vor. <strong>Du musst nichts weiter tun</strong>: Die Erstattung ist bereits als Abrechnung
eingereicht und wird nun bearbeitet.
</p>
@endif
<table style="border-collapse: collapse; margin: 16px 0;"> <table style="border-collapse: collapse; margin: 16px 0;">
<tr> <tr>
@@ -16,6 +28,12 @@
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td> <td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
<td style="padding: 4px 0;">{{$reason}}</td> <td style="padding: 4px 0;">{{$reason}}</td>
</tr> </tr>
@if ($invoiceNumber)
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Abrechnungsnummer:</td>
<td style="padding: 4px 0;">{{$invoiceNumber}}</td>
</tr>
@endif
<tr> <tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td> <td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
<td style="padding: 4px 0;">{{$accountOwner}}</td> <td style="padding: 4px 0;">{{$accountOwner}}</td>
@@ -26,15 +44,30 @@
</tr> </tr>
</table> </table>
@if ($hasRetention)
<p style="padding: 10px 12px; border-left: 3px solid #f5c400; background-color: #fffef5;">
Von deinem gezahlten Beitrag verbleiben <strong>{{$retainedAmount}}</strong> beim Verband.<br />
Grund: {{$retentionReason}}@if ($retentionReasonNote) &ndash; {{$retentionReasonNote}}@endif
</p>
@endif
@if ($hasDocument) @if ($hasDocument)
<p> <p>
Im Anhang findest du den Beleg über die Rückerstattung als PDF. Im Anhang findest du deinen Beleg über die Rückerstattung als PDF &ndash; nur zu deiner
Information, eingereicht ist er bereits.
</p>
@endif
@if ($myInvoicesUrl)
<p>
Den Stand deiner Abrechnung kannst du jederzeit selbst nachsehen: Melde dich in mareike an und
öffne <a href="{{$myInvoicesUrl}}">Meine Abrechnungen</a>.
</p> </p>
@endif @endif
<p> <p>
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
dich bitte umgehend bei ihr. Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
</p> </p>
<p> <p>
@@ -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 }}
&middot; {{ $event->start_date->format('d.m.Y') }}&ndash;{{ $event->end_date->format('d.m.Y') }}
@if($event->location)&middot; {{ $event->location }}@endif
&middot; 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']) }} &euro;</td>
</tr>
@foreach($category['entries'] as $entry)
<tr class="entry">
<td class="label">{{ $entry['name'] }}</td>
<td class="value">{{ $money($entry['amount']) }} &euro;</td>
</tr>
@endforeach
@endforeach
<tr class="sum">
<td>Summe Einnahmen</td>
<td class="value">{{ $money($income['total']) }} &euro;</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']) }} &euro;</td>
</tr>
@endforeach
<tr class="sum">
<td>Summe Ausgaben</td>
<td class="value">{{ $money($expenses['total']) }} &euro;</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) }} &euro;
</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 }} &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
@endif
</body>
</html>
+1
View File
@@ -57,6 +57,7 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/retrieve-invoice-types-all', [GlobalDataProvider::class, 'getAllInvoiceTypes']); Route::get('/retrieve-invoice-types-all', [GlobalDataProvider::class, 'getAllInvoiceTypes']);
Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']); Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']);
Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']); Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']);
Route::get('/retrieve-retention-reasons', [GlobalDataProvider::class, 'getRetentionReasons']);
}); });
}); });
Binary file not shown.
Binary file not shown.
+324
View File
@@ -0,0 +1,324 @@
<?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,
]);
}
}
@@ -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,
]);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use App\Enumerations\InvoiceType;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Welche Auslagentypen zur Auswahl stehen.
*
* Die Beitragserstattung entsteht ausschließlich aus einem bestätigten Erstattungsvorgang, der die
* Bankverbindung, den Beleg und den Bezug zur Anmeldung mitbringt. Von Hand gewählt stünde ein leerer
* Rahmen ohne diesen Vorgang da -- deshalb darf sie in keinem Formular auftauchen.
*/
class InvoiceTypeSelectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'email' => 'wm@example.com',
'email_finance' => 'wm-f@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Wilde Möhre e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $tenant);
// 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',
'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`.
$type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
$this->assertNotNull($type, 'Der Typ muss existieren -- invoices.type ist ein Fremdschlüssel darauf.');
$this->assertFalse($type->selectable);
}
public function test_it_is_missing_from_the_new_invoice_form(): void
{
$response = $this->getJson('/api/v1/core/retrieve-invoice-types');
$response->assertOk();
$slugs = array_column($response->json('invoiceTypes'), 'slug');
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_OTHER, $slugs);
}
public function test_it_is_missing_when_a_treasurer_rebooks(): void
{
// Ohne diese Sperre ließe sich jede beliebige Abrechnung nachträglich zu einer
// Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
$response = $this->getJson('/api/v1/core/retrieve-invoice-types-all');
$response->assertOk();
$slugs = array_column($response->json('invoiceTypes'), 'slug');
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
// "Sonstige Kosten" steht dort bewusst am Ende.
$this->assertSame(InvoiceType::INVOICE_TYPE_OTHER, end($slugs));
}
public function test_selectable_returns_only_choosable_types_in_order(): void
{
$this->assertSame(
[InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
InvoiceType::selectable()->pluck('slug')->all()
);
}
public function test_the_refund_type_does_not_count_as_an_expense(): void
{
// Sie mindert die Einnahmenseite bereits -- als Ausgabe gezählt, stünde sie zweimal in der Bilanz.
$this->assertSame(
[InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
InvoiceType::countingAsExpense()->pluck('slug')->all()
);
$type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
$this->assertFalse($type->counts_as_expense);
}
}
+108 -4
View File
@@ -10,10 +10,15 @@ use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocum
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand; use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest; use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\EfzStatus; use App\Enumerations\EfzStatus;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail; use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail; use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Models\Event; use App\Models\Event;
use App\Models\EventParticipant; use App\Models\EventParticipant;
@@ -67,6 +72,10 @@ class ParticipantRefundTest extends TestCase
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
// Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen.
// `invoice_types.participation_refund` bringt die Migration bereits mit.
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]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
@@ -112,6 +121,20 @@ class ParticipantRefundTest extends TestCase
]); ]);
} }
/** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */
private function makeCostUnit(array $attributes = []): CostUnit
{
return CostUnit::create(array_merge([
'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,
], $attributes));
}
private function makeEvent(array $attributes = []): Event private function makeEvent(array $attributes = []): Event
{ {
$fee = EventParticipationFee::create([ $fee = EventParticipationFee::create([
@@ -125,6 +148,7 @@ class ParticipantRefundTest extends TestCase
]); ]);
return Event::create(array_merge([ return Event::create(array_merge([
'cost_unit_id' => $this->makeCostUnit()->id,
'tenant' => $this->tenant->slug, 'tenant' => $this->tenant->slug,
'name' => 'Sommerlager', 'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(), 'identifier' => 'evt-' . uniqid(),
@@ -183,17 +207,23 @@ class ParticipantRefundTest extends TestCase
], $attributes)); ], $attributes));
} }
/**
* Der Vorgabewert 300,00 entspricht dem gezahlten Beitrag -- es bleibt also nichts einbehalten und
* es braucht keinen Einbehaltungsgrund. Bei kleineren Beträgen muss einer mitgegeben werden.
*/
private function release( private function release(
EventParticipant $participant, EventParticipant $participant,
float $amount = 300.0, float $amount = 300.0,
string $reason = RefundReason::SICKNESS, string $reason = RefundReason::SICKNESS,
?string $note = null, ?string $note = null,
?string $retentionReason = null,
) { ) {
return new ReleaseRefundCommand(new ReleaseRefundRequest( return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant, participant: $participant,
amount: new Amount($amount, 'Euro'), amount: new Amount($amount, 'Euro'),
reason: $reason, reason: $reason,
reasonNote: $note, reasonNote: $note,
retentionReason: $retentionReason,
))->execute(); ))->execute();
} }
@@ -273,10 +303,12 @@ class ParticipantRefundTest extends TestCase
{ {
$participant = $this->makeParticipant($this->makeEvent()); $participant = $this->makeParticipant($this->makeEvent());
$response = $this->release($participant, 220.0); $response = $this->release($participant, 220.0, retentionReason: RetentionReason::CANCELLATION_FEE);
$this->assertTrue($response->success); $this->assertTrue($response->success);
$this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001); $this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001);
// Der Rest wird am Vorgang festgeschrieben, nicht später gerechnet.
$this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
} }
public function test_release_is_rejected_without_an_amount(): void public function test_release_is_rejected_without_an_amount(): void
@@ -532,18 +564,32 @@ class ParticipantRefundTest extends TestCase
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function test_amount_paid_is_untouched_through_the_whole_process(): void public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void
{ {
$participant = $this->makeParticipant($this->makeEvent()); $participant = $this->makeParticipant($this->makeEvent());
// Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag
// unverändert beim Verband.
$refund = $this->release($participant)->refund; $refund = $this->release($participant)->refund;
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->accept($refund); $this->accept($refund);
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
// Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
} }
public function test_a_cancelled_release_leaves_amount_paid_alone(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite | Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
@@ -565,6 +611,22 @@ class ParticipantRefundTest extends TestCase
]); ]);
} }
/** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */
private function makeParticipantUser(): User
{
return User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
public function test_release_over_http_creates_the_refund(): void public function test_release_over_http_creates_the_refund(): void
{ {
$this->actingAs($this->makeAdmin()); $this->actingAs($this->makeAdmin());
@@ -573,6 +635,8 @@ class ParticipantRefundTest extends TestCase
$response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ $response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,50', 'amount' => '220,50',
'reason' => RefundReason::SICKNESS, 'reason' => RefundReason::SICKNESS,
// Weniger als gezahlt -- der Rest bleibt beim Verband und braucht eine Begründung.
'retentionReason' => RetentionReason::CANCELLATION_FEE,
]); ]);
$response->assertOk()->assertJsonPath('status', 'success'); $response->assertOk()->assertJsonPath('status', 'success');
@@ -715,7 +779,13 @@ class ParticipantRefundTest extends TestCase
public function test_the_release_mail_renders_with_the_link(): void public function test_the_release_mail_renders_with_the_link(): void
{ {
$participant = $this->makeParticipant($this->makeEvent()); $participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund; $refund = $this->release(
$participant,
220.0,
RefundReason::OTHER,
'Umzug',
RetentionReason::CANCELLATION_FEE
)->refund;
$html = new RefundReleasedMail($participant, $refund)->render(); $html = new RefundReleasedMail($participant, $refund)->render();
@@ -753,6 +823,40 @@ class ParticipantRefundTest extends TestCase
$this->assertStringContainsString('Anhang', $html); $this->assertStringContainsString('Anhang', $html);
} }
public function test_the_acceptance_mail_points_at_the_invoice(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]);
$refund = $this->release($participant)->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
// Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits.
$this->assertStringContainsString('musst nichts weiter tun', $html);
$this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html);
$this->assertStringContainsString('/invoice/my-invoices/new', $html);
}
public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void
{
// Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung.
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
$refund = $this->release($participant)->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
$this->assertStringNotContainsString('/invoice/my-invoices/new', $html);
}
public function test_an_empty_second_address_gets_no_mail(): void
{
// Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL.
$this->release($this->makeParticipant($this->makeEvent(), ['email_2' => '']));
Mail::assertSent(RefundReleasedMail::class, 1);
}
public function test_the_acceptance_mail_renders_without_a_document(): void public function test_the_acceptance_mail_renders_without_a_document(): void
{ {
$participant = $this->makeParticipant($this->makeEvent()); $participant = $this->makeParticipant($this->makeEvent());
+434
View File
@@ -0,0 +1,434 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\Invoice;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Der Direktweg: Liegt der Aktionsleitung die Bankverbindung bereits vor, entfällt der Umweg über den
* Teili -- die Erstattung wird sofort eingereicht.
*/
class RefundDirectCaptureTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private User $management;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('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']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
$this->seedTemplate();
// Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang.
$this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN);
$this->actingAs($this->management);
Storage::fake('local');
Mail::fake();
}
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{declaration_text}</p>'
. '{if:capture_note}<p class="capture-note">{capture_note}</p>{/if:capture_note}',
'sort_order' => 20,
]);
}
private function makeUser(string $firstname, string $lastname, string $role): User
{
return User::create([
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'firstname' => $firstname,
'lastname' => $lastname,
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => $role,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
private function makeEvent(array $attributes = []): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug,
'type' => 'participant',
'name' => 'Sippe',
'description' => null,
'amount_standard' => 60.0,
'amount_reduced' => null,
'amount_solidarity' => null,
]);
$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,
]);
return Event::create(array_merge([
'cost_unit_id' => $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',
], $attributes));
}
private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant
{
$event ??= $this->makeEvent();
$this->sequence++;
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
'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' => 300.0,
'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
], $attributes));
}
/**
* Freigabe mit bereits bekannter Bankverbindung.
*
* 220 von 300 gezahlten Euro: Es bleibt etwas beim Verband, deshalb gehört ein Einbehaltungsgrund
* dazu.
*/
private function releaseWithBankDetails(
?EventParticipant $participant = null,
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
float $amount = 220.0,
?string $retentionReason = RetentionReason::CANCELLATION_FEE,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant ?? $this->makeParticipant(),
amount: new Amount($amount, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: $owner,
accountIban: $iban,
retentionReason: $retentionReason,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Der Vorgang ist sofort abgeschlossen
|--------------------------------------------------------------------------
*/
public function test_the_refund_is_submitted_right_away(): void
{
$participant = $this->makeParticipant();
$response = $this->releaseWithBankDetails($participant);
$this->assertTrue($response->success);
$this->assertStringContainsString('eingereicht', $response->message);
$refund = ParticipantRefund::first();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertSame('Mika Muster', $refund->account_owner);
$this->assertSame('DE02120300000000202051', $refund->account_iban);
$this->assertNotNull($refund->accepted_at);
}
public function test_the_capturing_person_is_recorded(): void
{
$this->releaseWithBankDetails();
$refund = ParticipantRefund::first();
$this->assertSame($this->management->id, $refund->captured_by);
$this->assertTrue($refund->wasCapturedByManagement());
}
public function test_the_invoice_exists_and_the_paid_amount_is_settled(): void
{
$participant = $this->makeParticipant();
$this->releaseWithBankDetails($participant);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
// 300 gezahlt, 220 erstattet -- die restlichen 80 bleiben beim Verband und werden dort geführt.
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_a_full_refund_leaves_nothing_behind(): void
{
$participant = $this->makeParticipant();
$this->releaseWithBankDetails($participant, amount: 300.0, retentionReason: null);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->assertEqualsWithDelta(0.0, ParticipantRefund::first()->retained_amount->getAmount(), 0.001);
$this->assertNull(ParticipantRefund::first()->retention_reason);
}
public function test_only_the_receipt_mail_goes_out(): void
{
$this->releaseWithBankDetails();
// Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos.
Mail::assertNotSent(RefundReleasedMail::class);
Mail::assertSent(RefundAcceptedMail::class);
}
/*
|--------------------------------------------------------------------------
| Der Beleg weist aus, wer die Angaben aufgenommen hat
|--------------------------------------------------------------------------
*/
public function test_the_receipt_names_who_captured_the_details(): void
{
$this->releaseWithBankDetails();
$html = $this->renderReceipt(ParticipantRefund::first());
// Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst.
$this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html);
$this->assertStringContainsString('Ich versichere', $html);
}
public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void
{
// Gegenprobe: der gewöhnliche Weg bleibt unverändert.
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $this->makeParticipant(),
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
))->execute()->refund;
$refund->update([
'status' => ParticipantRefund::STATUS_ACCEPTED,
'account_owner' => 'Mika Muster',
'account_iban' => 'DE02120300000000202051',
'accepted_at' => now(),
]);
$this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh()));
}
private function renderReceipt(ParticipantRefund $refund): string
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
$number = new \ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
return new \App\Providers\DocumentTemplateRenderProvider(
DocumentTemplate::TYPE_PARTICIPANT_REFUND
)->render($tokens);
}
/*
|--------------------------------------------------------------------------
| Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen
|--------------------------------------------------------------------------
*/
public function test_an_invalid_iban_stops_everything(): void
{
// Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher.
$response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015');
$this->assertFalse($response->success);
$this->assertStringContainsString('IBAN', $response->message);
$this->assertSame(0, ParticipantRefund::count());
$this->assertSame(0, Invoice::count());
}
public function test_half_filled_bank_details_are_refused(): void
{
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $this->makeParticipant(),
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: 'Mika Muster',
))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_without_a_cost_unit_nothing_is_created(): void
{
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$response = $this->releaseWithBankDetails($participant);
$this->assertFalse($response->success);
$this->assertStringContainsString('Kostenstelle', $response->message);
$this->assertSame(0, ParticipantRefund::count());
$this->assertSame(0, Invoice::count());
// Der gezahlte Beitrag bleibt unangetastet.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Über HTTP
|--------------------------------------------------------------------------
*/
public function test_release_over_http_submits_directly(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
// 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED);
$this->assertNotNull(ParticipantRefund::first()->invoice_id);
}
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
// 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => '',
'accountIban' => '',
])
->assertOk()
->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
Mail::assertSent(RefundReleasedMail::class);
$this->assertSame(0, Invoice::count());
$this->assertNull(ParticipantRefund::first()->captured_by);
}
}
+436
View File
@@ -0,0 +1,436 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\Invoice;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Der Anschluss an die Buchhaltung: aus einer bestätigten Erstattung wird eine gewöhnliche
* Auslagenabrechnung, die den regulären Weg bis zur SEPA-Datei geht.
*/
class RefundInvoiceTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('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']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
$this->seedTemplate();
// Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage.
Storage::fake('local');
Mail::fake();
}
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '<div>{recipient_name}</div>{details_table}',
'sort_order' => 20,
]);
}
private function makeCostUnit(array $attributes = []): CostUnit
{
return CostUnit::create(array_merge([
'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,
], $attributes));
}
private function makeEvent(array $attributes = []): 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(array_merge([
'cost_unit_id' => $this->makeCostUnit()->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',
], $attributes));
}
private function makeUser(): User
{
return User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
private function makeParticipant(Event $event, array $attributes = []): EventParticipant
{
$this->sequence++;
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser()->id,
'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' => 300.0,
'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
], $attributes));
}
/**
* Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili.
*
* 220 von 300 gezahlten Euro -- es bleibt etwas beim Verband, deshalb der Einbehaltungsgrund.
*/
private function runRefund(
?EventParticipant $participant = null,
float $amount = 220.0,
string $reason = RefundReason::SICKNESS,
?string $retentionReason = RetentionReason::CANCELLATION_FEE,
): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent());
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
retentionReason: $retentionReason,
))->execute()->refund;
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
return $refund->fresh();
}
/*
|--------------------------------------------------------------------------
| Die Abrechnung entsteht
|--------------------------------------------------------------------------
*/
public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
$this->assertSame($event->cost_unit_id, $invoice->cost_unit_id);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertFalse((bool) $invoice->donation);
}
public function test_contact_details_come_from_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_name);
$this->assertSame('mika@example.com', $invoice->contact_email);
$this->assertSame('0170 0000000', $invoice->contact_phone);
}
public function test_bank_details_come_from_the_refund_not_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_bank_owner);
$this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban);
}
public function test_the_invoice_belongs_to_the_participants_user_account(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertSame($participant->user_id, $invoice->user_id);
// Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id,
// sobald ein Verwendungszweck gesetzt ist.
$this->assertNull($invoice->payment_purpose);
}
public function test_an_anonymous_participant_still_gets_an_invoice(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertNull($invoice->user_id);
}
public function test_the_notice_names_event_reason_and_the_amount_paid(): void
{
$this->runRefund();
// Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird --
// sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen.
$this->assertSame(
'Rückerstattung Teilnahmebeitrag Sommerlager Krankheitsbedingte Absage'
. ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro',
Invoice::first()->comment
);
}
public function test_a_long_notice_keeps_the_amount_paid(): void
{
$event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]);
$this->runRefund($this->makeParticipant($event));
$comment = Invoice::first()->comment;
// Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen.
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment);
$this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.');
}
/*
|--------------------------------------------------------------------------
| Der Eigenbeleg hängt an der Abrechnung
|--------------------------------------------------------------------------
*/
public function test_the_receipt_is_attached_and_stored(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice->document_filename);
// Dasselbe Ablagemuster wie bei hochgeladenen Belegen.
$this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename);
$this->assertStringEndsWith('.pdf', $invoice->document_filename);
Storage::disk('local')->assertExists($invoice->document_filename);
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
}
public function test_the_refund_points_at_its_invoice(): void
{
$refund = $this->runRefund();
$this->assertSame(Invoice::first()->id, $refund->invoice_id);
$this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number);
}
/*
|--------------------------------------------------------------------------
| Die Kostenstelle
|--------------------------------------------------------------------------
*/
public function test_without_a_cost_unit_nothing_is_stored(): void
{
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
))->execute()->refund;
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Aktionsleitung', $response->message);
// Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann.
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertSame(0, Invoice::count());
}
public function test_a_closed_cost_unit_is_still_booked(): void
{
// Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist.
$costUnit = $this->makeCostUnit(['allow_new' => false]);
$event = $this->makeEvent(['cost_unit_id' => $costUnit->id]);
$this->runRefund($this->makeParticipant($event));
$this->assertSame($costUnit->id, Invoice::first()->cost_unit_id);
}
/*
|--------------------------------------------------------------------------
| Der reguläre Weg läuft mit
|--------------------------------------------------------------------------
*/
public function test_the_standard_confirmation_mail_is_sent(): void
{
$this->runRefund();
// Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg.
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
}
public function test_amount_paid_is_settled_once_the_invoice_exists(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
// 300 gezahlt, 220 erstattet: `amount_paid` führt danach den Rest, der beim Verband bleibt.
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
// Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
public function test_the_receipt_is_written_before_the_amount_is_settled(): void
{
$refund = $this->runRefund();
// Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh
// verrechnet, stünden dort 80,00 € statt 300,00 €. Der Beleg selbst liegt als PDF vor;
// nachprüfbar ist die Reihenfolge an der Anmerkung, die im selben Schritt entsteht.
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
$this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
}
}
+407
View File
@@ -0,0 +1,407 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\Providers\DocumentTemplateRenderProvider;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use ReflectionMethod;
use Tests\TestCase;
/**
* Der einbehaltene Teil einer Erstattung.
*
* Wird weniger erstattet als gezahlt wurde, bleibt Geld beim Verband. Das muss begründet sein und überall
* sichtbar bleiben -- und `amount_paid` muss es weiter führen, weil die Einnahmenrechnung darauf aufbaut.
*/
class RefundRetentionTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'address_1' => 'Musterweg 1',
'email' => 't@example.com',
'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.',
'account_iban' => 'DE00',
'account_bic' => 'XY',
'city' => 'Stadt',
'postcode' => '00000',
'invoice_prefix' => 'WM',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('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']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
$this->seedTemplate();
Storage::fake('local');
Mail::fake();
}
/** Die Vorlage setzt den Datenblock ein -- daran lassen sich die Belegzeilen prüfen. */
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{retained_amount} / {retention_note}</p>',
'sort_order' => 20,
]);
}
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,
]);
$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,
]);
return Event::create([
'cost_unit_id' => $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',
// Je Test können mehrere Veranstaltungen entstehen; der Schlüssel ist eindeutig.
'invoice_key' => 'WM-V-2026070' . ($this->sequence + 1),
]);
}
private function makeParticipant(array $attributes = []): EventParticipant
{
$this->sequence++;
$user = User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
return $this->makeEvent()->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $user->id,
'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' => 300.0,
'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
], $attributes));
}
private function release(
EventParticipant $participant,
float $amount,
?string $retentionReason = null,
?string $retentionNote = null,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: $retentionReason,
retentionReasonNote: $retentionNote,
))->execute();
}
private function accept(ParticipantRefund $refund): void
{
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Der Grund ist Pflicht, sobald etwas bleibt
|--------------------------------------------------------------------------
*/
public function test_a_partial_refund_without_a_reason_is_refused(): void
{
$response = $this->release($this->makeParticipant(), 220.0);
$this->assertFalse($response->success);
$this->assertStringContainsString('einbehalten', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_full_refund_needs_no_reason(): void
{
$response = $this->release($this->makeParticipant(), 300.0);
$this->assertTrue($response->success);
$this->assertNull($response->refund->retention_reason);
$this->assertEqualsWithDelta(0.0, $response->refund->retained_amount->getAmount(), 0.001);
}
public function test_a_free_text_retention_reason_needs_its_note(): void
{
$response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, ' ');
$this->assertFalse($response->success);
$this->assertStringContainsString('Erläuterung', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_an_unknown_retention_reason_is_refused(): void
{
$response = $this->release($this->makeParticipant(), 220.0, 'erfunden');
$this->assertFalse($response->success);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_reason_sent_with_a_full_refund_is_not_stored(): void
{
// Im Formular ist das Feld dann gar nicht sichtbar -- ein Wert ohne Bezug gehört nicht in die DB.
$response = $this->release($this->makeParticipant(), 300.0, RetentionReason::CANCELLATION_FEE);
$this->assertTrue($response->success);
$this->assertNull($response->refund->retention_reason);
}
public function test_the_note_is_only_stored_for_reasons_that_require_it(): void
{
$withNote = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt');
$this->assertSame('Bereits gebuchte Bahnfahrt', $withNote->refund->retention_reason_note);
$ignored = $this->release($this->makeParticipant(), 220.0, RetentionReason::MATERIAL, 'wird verworfen');
$this->assertNull($ignored->refund->retention_reason_note);
}
/*
|--------------------------------------------------------------------------
| Was beim Verband bleibt
|--------------------------------------------------------------------------
*/
public function test_the_retained_amount_is_recorded_at_release(): void
{
$response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CANCELLATION_FEE);
// Festgeschrieben, nicht gerechnet: Nach dem Einreichen führt `amount_paid` bereits den Rest.
$this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
$this->assertTrue($response->refund->hasRetention());
}
public function test_amount_paid_keeps_the_retained_share(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_a_full_refund_leaves_nothing(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_a_second_refund_is_capped_at_the_remainder(): void
{
$participant = $this->makeParticipant();
$this->accept($this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund);
// Es liegen noch 80 € beim Verband -- mehr kann nicht zurückgehen.
$tooMuch = $this->release($participant->fresh(), 100.0, RetentionReason::CANCELLATION_FEE);
$this->assertFalse($tooMuch->success);
$fits = $this->release($participant->fresh(), 80.0);
$this->assertTrue($fits->success);
$this->assertEqualsWithDelta(0.0, $fits->refund->retained_amount->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Sichtbarkeit
|--------------------------------------------------------------------------
*/
public function test_the_receipt_names_the_retained_amount_and_reason(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$html = $this->renderReceipt($refund->fresh());
$this->assertStringContainsString('Einbehalten', $html);
$this->assertStringContainsString('80,00', $html);
$this->assertStringContainsString('Stornogebühr laut Ausschreibung', $html);
}
public function test_the_receipt_stays_silent_on_a_full_refund(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertStringNotContainsString('Einbehalten', $this->renderReceipt($refund->fresh()));
}
public function test_the_mail_explains_the_retention(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt')->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
$this->assertStringContainsString('80,00 Euro', $html);
$this->assertStringContainsString('Sonstiger Grund', $html);
$this->assertStringContainsString('Bereits gebuchte Bahnfahrt', $html);
}
public function test_the_mail_stays_silent_on_a_full_refund(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertStringNotContainsString('verbleiben beim Verband', new RefundAcceptedMail($participant, $refund->fresh())->render());
}
public function test_the_resource_carries_the_retention_for_the_lists(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$data = $refund->fresh()->toResource()->toArray(request());
$this->assertTrue($data['hasRetention']);
$this->assertSame('80,00 Euro', $data['retainedAmount']);
$this->assertSame('Stornogebühr laut Ausschreibung', $data['retentionReasonLabel']);
}
private function renderReceipt(ParticipantRefund $refund): string
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
}
}
+1 -1
View File
@@ -1 +1 @@
4.7.0 4.8.0