Refundings without contacting the participant

This commit is contained in:
2026-09-03 23:36:21 +02:00
parent efee20c16b
commit 5beb2b1d97
16 changed files with 723 additions and 37 deletions
@@ -10,6 +10,7 @@ import AmountInput from "../../../../Views/Components/AmountInput.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue";
import IbanInput from "../../../../Views/Components/IbanInput.vue";
const props = defineProps({
data: {
@@ -47,10 +48,11 @@ const openCancelDialog = ref(false);
const openPartialPaymentDialogSwitch = ref(false);
const openRefundDialogSwitch = ref(false);
// Der Erstattungsdialog. Betrag und Grund werden hier gesetzt; die Bankverbindung erfasst der Teili
// selbst über den Link, den die Freigabe ihm schickt.
const refundForm = reactive({amount: '', reason: '', reasonNote: ''});
const refundErrors = reactive({amount: '', reason: '', reasonNote: ''});
// Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über
// den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits
// vor -- dann wird die Erstattung sofort eingereicht.
const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''});
const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''});
const refundReasons = ref([]);
const refundSaving = ref(false);
@@ -317,9 +319,12 @@ async function openRefundDialog(participant) {
refundForm.amount = participant.amountPaid?.short ?? '';
refundForm.reason = '';
refundForm.reasonNote = '';
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
// Vorgabe ist der übliche Weg über den Teili.
refundForm.captureMode = 'participant';
refundForm.accountOwner = '';
refundForm.accountIban = '';
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
if (refundReasons.value.length === 0) {
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
@@ -333,9 +338,7 @@ function validateRefund() {
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
if (!refundForm.amount || !(amount > 0)) {
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
@@ -349,7 +352,19 @@ function validateRefund() {
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() {
@@ -366,13 +381,22 @@ async function execRefund() {
amount: refundForm.amount,
reason: refundForm.reason,
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 : '',
},
});
if (data?.status === 'success') {
toast.success(data.message);
// 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;
// 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;
} else {
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
@@ -603,8 +627,7 @@ function mailToGroup(groupKey) {
>
<p class="refund-intro">
{{ showParticipant?.fullname }} hat
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt. Nach der Freigabe erhält
der Teili eine E-Mail und trägt seine Bankverbindung selbst ein.
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt.
</p>
<div class="refund-field">
@@ -632,8 +655,49 @@ function mailToGroup(groupKey) {
<ErrorText :message="refundErrors.reasonNote" />
</div>
<!--
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>
<button class="button" :disabled="refundSaving" @click="execRefund()">
{{ refundSaving ? 'Wird freigegeben' : 'Erstattung freigeben' }}
<template v-if="refundSaving">Wird gespeichert</template>
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
<template v-else>Erstattung freigeben</template>
</button>
</Modal>
@@ -668,10 +732,32 @@ function mailToGroup(groupKey) {
}
.refund-field select,
.refund-field textarea {
.refund-field textarea,
.refund-field .form-input {
width: 100%;
}
.refund-choice {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #1a1a1a;
cursor: pointer;
}
.refund-choice input {
margin-right: 6px;
}
.refund-hint {
margin-bottom: 14px;
padding: 8px 10px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.85rem;
color: #4b5563;
}
.participants-table {
width: 95%;
margin: 20px auto;
@@ -67,7 +67,10 @@ class AcceptRefundCommand
// 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
// 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.';
}
@@ -107,6 +110,7 @@ class AcceptRefundCommand
$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();
@@ -12,6 +12,13 @@ class AcceptRefundRequest
public readonly string $accountIban,
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
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,28 @@ class CreateRefundDocumentCommand
* 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.
*/
/**
* 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
{
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
@@ -178,6 +200,7 @@ class CreateRefundDocumentCommand
'account_iban' => $this->formatIban((string) $refund->account_iban),
'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(),
'details_table' => $this->renderDetails(),
];
@@ -2,13 +2,18 @@
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\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Repositories\ParticipantRefundRepository;
use App\Support\Iban;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Gibt die Erstattung eines Teilnahmebeitrags frei.
@@ -40,6 +45,9 @@ class ReleaseRefundCommand
return $response;
}
// Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die,
// soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion.
$refund = DB::transaction(function (): ParticipantRefund {
$refund = ParticipantRefund::create([
'tenant' => $this->participant->tenant,
'event_id' => $this->participant->event_id,
@@ -53,15 +61,50 @@ class ReleaseRefundCommand
'released_at' => now(),
]);
if ($this->request->hasBankDetails()) {
$this->submitDirectly($refund);
}
return $refund;
});
if (!$this->request->hasBankDetails()) {
$this->notify($refund);
}
$response->success = true;
$response->refund = $refund;
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
$response->refund = $refund->fresh();
$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;
}
/**
* 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: auth()->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.
*
@@ -98,6 +141,34 @@ class ReleaseRefundCommand
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
}
return $this->rejectBankDetails();
}
/**
* 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;
}
@@ -12,6 +12,20 @@ class ReleaseRefundRequest
public readonly Amount $amount,
public readonly string $reason,
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,
) {
}
/** Ob die Erstattung ohne Zutun des Teilis eingereicht werden kann. */
public function hasBankDetails(): bool
{
return filled($this->accountOwner) && filled($this->accountIban);
}
}
@@ -25,6 +25,9 @@ class ReleaseRefundController extends CommonController
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
reason: (string) $request->input('reason'),
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')),
);
$response = new ReleaseRefundCommand($refundRequest)->execute();
@@ -66,6 +66,7 @@ final class ParticipantRefundTokens
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'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.'],
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
],
],
'body' => [
@@ -54,6 +54,9 @@ class RefundAcceptedMail extends Mailable
'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null,
'invoiceNumber' => $invoice?->invoice_number,
// 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,
+16
View File
@@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $reason_note
* @property string|null $account_owner
* @property string|null $account_iban
* @property int|null $captured_by
* @property int|null $invoice_id
* @property int|null $released_by
* @property \Illuminate\Support\Carbon|null $released_at
@@ -52,6 +53,7 @@ class ParticipantRefund extends InstancedModel
'reason_note',
'account_owner',
'account_iban',
'captured_by',
'invoice_id',
'released_by',
'released_at',
@@ -93,6 +95,20 @@ class ParticipantRefund extends InstancedModel
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
{
return $this->status === self::STATUS_PENDING;
@@ -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');
});
}
};
@@ -84,6 +84,9 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
/* Die Versicherung des Teilis unter den Angaben */
.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. */
.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()),
@@ -130,5 +133,6 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
{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());
@@ -2,11 +2,22 @@
<html>
<body>
<h1>Hallo {{$name}}!</h1>
@if ($capturedByManagement)
<p>
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
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;">
<tr>
Binary file not shown.
Binary file not shown.
+409
View File
@@ -0,0 +1,409 @@
<?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\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. */
private function releaseWithBankDetails(
?EventParticipant $participant = null,
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
float $amount = 220.0,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant ?? $this->makeParticipant(),
amount: new Amount($amount, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: $owner,
accountIban: $iban,
))->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_cleared(): 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);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
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,
))->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,
'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,
'accountOwner' => '',
'accountIban' => '',
])
->assertOk()
->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
Mail::assertSent(RefundReleasedMail::class);
$this->assertSame(0, Invoice::count());
$this->assertNull(ParticipantRefund::first()->captured_by);
}
}