Dev 4.8.0 #15

Merged
th.guenther merged 11 commits from dev-4.8.0 into main 2026-09-04 09:29:57 +02:00
55 changed files with 3985 additions and 42 deletions
Showing only changes of commit 6a183d6498 - Show all commits
+3
View File
@@ -23,3 +23,6 @@ Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/docker-compose.yaml /docker-compose.yaml
# HTML-Report von composer test:coverage
/storage/coverage
@@ -2,47 +2,38 @@
namespace App\Domains\Admin\Controllers; namespace App\Domains\Admin\Controllers;
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
use App\Models\DocumentAsset; use App\Models\DocumentAsset;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Models\DocumentTypeCatalog;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentTemplatesGetController extends CommonController class DocumentTemplatesGetController extends CommonController
{ {
/** Blöcke, die Layout-HTML bzw. CSS enthalten und deshalb im Quelltext-Editor gepflegt werden. */ public function __invoke(Request $request): JsonResponse
private const array SOURCE_BLOCKS = [
DocumentTemplate::BLOCK_LAYOUT,
DocumentTemplate::BLOCK_STYLE,
];
private const array BLOCK_LABELS = [
DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst',
DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)',
'header_sender_return' => 'Rücksendezeile',
'header_recipient' => 'Empfängeranschrift',
'emblem' => 'Emblem',
'logo' => 'Logo',
'sender_data' => 'Absenderangaben',
'subject' => 'Betreff und Referenzdaten',
DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt',
'footer' => 'Grußformel und Fußnote',
];
public function __invoke(): JsonResponse
{ {
$blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE) $documentType = (string) $request->input('type', DocumentTypeCatalog::default());
if (!DocumentTypeCatalog::has($documentType)) {
abort(422, 'Unbekannte Dokumentart.');
}
$blocks = DocumentTemplate::forType($documentType)
->values() ->values()
->map(fn(DocumentTemplate $block): array => [ ->map(fn(DocumentTemplate $block): array => [
'block' => $block->block, 'block' => $block->block,
'label' => self::BLOCK_LABELS[$block->block] ?? $block->block, 'label' => DocumentTypeCatalog::blockLabel($documentType, $block->block),
'content' => (string) $block->content, 'content' => (string) $block->content,
'editable' => $block->editable, 'editable' => $block->editable,
'source' => in_array($block->block, self::SOURCE_BLOCKS, true), 'source' => in_array($block->block, DocumentTypeCatalog::SOURCE_BLOCKS, true),
]); ]);
return response()->json([ return response()->json([
'documentType' => $documentType,
'documentTypes' => DocumentTypeCatalog::options(),
'blocks' => $blocks, 'blocks' => $blocks,
// Die Bilder gelten für alle Dokumentarten -- sie hängen nicht am Typ.
'assets' => DocumentAsset::orderBy('name')->get()->map(fn(DocumentAsset $asset): array => [ 'assets' => DocumentAsset::orderBy('name')->get()->map(fn(DocumentAsset $asset): array => [
'name' => $asset->name, 'name' => $asset->name,
'label' => $asset->label, 'label' => $asset->label,
@@ -50,7 +41,7 @@ class DocumentTemplatesGetController extends CommonController
'token' => '{asset:' . $asset->name . '}', 'token' => '{asset:' . $asset->name . '}',
'preview' => $asset->toDataUri(), 'preview' => $asset->toDataUri(),
]), ]),
'tokenGroups' => ParticipantInvoiceTokens::groups(), 'tokenGroups' => DocumentTypeCatalog::tokenGroups($documentType),
]); ]);
} }
} }
@@ -2,8 +2,7 @@
namespace App\Domains\Admin\Controllers; namespace App\Domains\Admin\Controllers;
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens; use App\Models\DocumentTypeCatalog;
use App\Models\DocumentTemplate;
use App\Providers\DocumentTemplateRenderProvider; use App\Providers\DocumentTemplateRenderProvider;
use App\Providers\PdfGenerateAndDownloadProvider; use App\Providers\PdfGenerateAndDownloadProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
@@ -17,10 +16,16 @@ class DocumentTemplatesPreviewController extends CommonController
{ {
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
$documentType = (string) $request->input('type', DocumentTypeCatalog::default());
if (!DocumentTypeCatalog::has($documentType)) {
abort(422, 'Unbekannte Dokumentart.');
}
$html = new DocumentTemplateRenderProvider( $html = new DocumentTemplateRenderProvider(
DocumentTemplate::TYPE_PARTICIPANT_INVOICE, $documentType,
(array) $request->input('blocks', []), (array) $request->input('blocks', []),
)->render(ParticipantInvoiceTokens::sample()); )->render(DocumentTypeCatalog::sampleTokens($documentType));
return response(PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'), 200, [ return response(PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'), 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
@@ -4,7 +4,7 @@ namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateAction; use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateAction;
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateRequest; use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateRequest;
use App\Models\DocumentTemplate; use App\Models\DocumentTypeCatalog;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -13,8 +13,16 @@ class DocumentTemplatesUpdateController extends CommonController
{ {
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
$documentType = (string) $request->input('type', DocumentTypeCatalog::default());
// Die Dokumentart bestimmt, welche Zeilen geschrieben werden -- ungeprüft weitergereicht wäre
// sie ein Weg, in beliebige Vorlagen zu schreiben.
if (!DocumentTypeCatalog::has($documentType)) {
abort(422, 'Unbekannte Dokumentart.');
}
$action = new UpdateDocumentTemplateAction(new UpdateDocumentTemplateRequest( $action = new UpdateDocumentTemplateAction(new UpdateDocumentTemplateRequest(
documentType: DocumentTemplate::TYPE_PARTICIPANT_INVOICE, documentType: $documentType,
blocks: (array) $request->input('blocks', []), blocks: (array) $request->input('blocks', []),
)); ));
+55 -7
View File
@@ -12,6 +12,10 @@ const blocks = ref([])
const assets = ref([]) const assets = ref([])
const tokenGroups = ref({}) const tokenGroups = ref({})
// Die Vorlagen sind nach Dokumentart getrennt; der Umschalter lädt jeweils deren Blöcke neu.
const documentType = ref(null)
const documentTypes = ref([])
const activeBlock = ref(null) const activeBlock = ref(null)
const saving = ref(false) const saving = ref(false)
@@ -33,12 +37,15 @@ function selectBlock(block) {
onMounted(load) onMounted(load)
async function load() { async function load() {
const data = await request('/api/v1/admin/document-templates', {method: 'GET'}) const query = documentType.value ? '?type=' + encodeURIComponent(documentType.value) : ''
const data = await request('/api/v1/admin/document-templates' + query, {method: 'GET'})
if (!data) { if (!data) {
toast.error('Die Vorlage konnte nicht geladen werden.') toast.error('Die Vorlage konnte nicht geladen werden.')
return return
} }
documentType.value = data.documentType
documentTypes.value = data.documentTypes ?? []
blocks.value = data.blocks ?? [] blocks.value = data.blocks ?? []
assets.value = data.assets ?? [] assets.value = data.assets ?? []
tokenGroups.value = data.tokenGroups ?? {} tokenGroups.value = data.tokenGroups ?? {}
@@ -47,6 +54,15 @@ async function load() {
await refreshPreview() await refreshPreview()
} }
/** Beim Wechsel der Dokumentart alles neu holen -- Blöcke und Platzhalter sind je Art andere. */
async function selectDocumentType(type) {
if (type === documentType.value) return
documentType.value = type
caret.value = null
await load()
}
/** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */ /** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */
function editableBlocks() { function editableBlocks() {
return Object.fromEntries( return Object.fromEntries(
@@ -60,7 +76,7 @@ async function save() {
try { try {
const response = await request('/api/v1/admin/document-templates', { const response = await request('/api/v1/admin/document-templates', {
method: 'POST', method: 'POST',
body: {blocks: editableBlocks()}, body: {type: documentType.value, blocks: editableBlocks()},
}) })
if (response?.status === 'success') { if (response?.status === 'success') {
@@ -83,7 +99,7 @@ async function refreshPreview() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
}, },
body: JSON.stringify({blocks: editableBlocks()}), body: JSON.stringify({type: documentType.value, blocks: editableBlocks()}),
}) })
if (!response.ok) { if (!response.ok) {
@@ -217,14 +233,28 @@ function onFileChosen(event) {
</script> </script>
<template> <template>
<AdminAppLayout title="Rechnungsvorlage"> <AdminAppLayout title="Dokumentvorlagen">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px;"> <shadowed-box style="width: 95%; margin: 20px auto; padding: 20px;">
<p class="intro"> <p class="intro">
Die Vorlage gilt für <strong>alle Mandanten</strong>; die eingesetzten Werte kommen je Die Vorlagen gelten für <strong>alle Mandanten</strong>; die eingesetzten Werte kommen je
Rechnung aus dem jeweiligen Mandanten. Sie liegt in der Datenbank und wird von einem Dokument aus dem jeweiligen Mandanten. Sie liegen in der Datenbank und werden von einem
Update nicht überschrieben. Update nicht überschrieben.
</p> </p>
<div class="type-switch">
<label for="document-type">Dokumentart</label>
<select
id="document-type"
class="form-input"
:value="documentType"
@change="selectDocumentType($event.target.value)"
>
<option v-for="type in documentTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<div class="layout"> <div class="layout">
<!-- Blöcke und Editor --> <!-- Blöcke und Editor -->
<div class="editor-column"> <div class="editor-column">
@@ -243,7 +273,7 @@ function onFileChosen(event) {
<div v-if="current" class="editor-panel"> <div v-if="current" class="editor-panel">
<p v-if="!current.editable" class="notice"> <p v-if="!current.editable" class="notice">
Dieser Block wird beim Erzeugen der Rechnung aus den Daten der Anmeldung Dieser Block wird beim Erzeugen des Dokuments aus den Daten der Anmeldung
zusammengesetzt und lässt sich nicht bearbeiten. zusammengesetzt und lässt sich nicht bearbeiten.
</p> </p>
@@ -360,6 +390,24 @@ function onFileChosen(event) {
color: #4b5563; color: #4b5563;
} }
.type-switch {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.type-switch label {
font-weight: bold;
font-size: 0.9rem;
color: #4b5563;
}
.type-switch select {
width: auto;
min-width: 220px;
}
.layout { .layout {
display: flex; display: flex;
gap: 24px; gap: 24px;
@@ -377,6 +377,19 @@ function saveParticipant() {
</span> </span>
</td> </td>
</tr> </tr>
<tr v-if="props.participant.refund">
<th>Erstattung</th>
<td>
{{ props.participant.refund.amount }} &ndash; {{ props.participant.refund.reasonLabel }}<br />
<small v-if="props.participant.refund.status === 'pending'">
freigegeben am {{ props.participant.refund.releasedAt }}, wartet auf die
Bankverbindung des Teilis
</small>
<small v-else-if="props.participant.refund.status === 'accepted'">
bestätigt am {{ props.participant.refund.acceptedAt }}
</small>
</td>
</tr>
</table> </table>
</div> </div>
@@ -9,6 +9,7 @@ import {format} from "date-fns";
import AmountInput from "../../../../Views/Components/AmountInput.vue"; 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";
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -29,7 +30,7 @@ const props = defineProps({
const today = format(new Date(), "yyyy-MM-dd"); const today = format(new Date(), "yyyy-MM-dd");
const { request } = useAjax(); const { request, download } = useAjax();
const searchTerms = reactive({}); const searchTerms = reactive({});
const selectedStatuses = reactive({}); const selectedStatuses = reactive({});
@@ -44,6 +45,18 @@ const mailCompose = ref(false);
const openCancelDialog = ref(false); const openCancelDialog = ref(false);
const openPartialPaymentDialogSwitch = 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: ''});
const refundReasons = ref([]);
const refundSaving = ref(false);
const selectedRefundReason = computed(
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
);
defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete']) defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete'])
@@ -292,6 +305,104 @@ async function execPartialPayment() {
openPartialPaymentDialogSwitch.value = false; openPartialPaymentDialogSwitch.value = false;
} }
/**
* Erstattung freigeben.
*
* Der Betrag ist mit dem vor, was der Teili gezahlt hat -- das ist der Regelfall; Abzüge trägt die
* Aktionsleitung von Hand ein.
*/
async function openRefundDialog(participant) {
showParticipant.value = participant;
refundForm.amount = participant.amountPaid?.short ?? '';
refundForm.reason = '';
refundForm.reasonNote = '';
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
if (refundReasons.value.length === 0) {
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
refundReasons.value = reasons ?? [];
}
openRefundDialogSwitch.value = true;
}
function validateRefund() {
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
refundErrors.amount = '';
refundErrors.reason = '';
refundErrors.reasonNote = '';
if (!refundForm.amount || !(amount > 0)) {
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
} else if (amount > paid + 0.005) {
refundErrors.amount = 'Mehr als der gezahlte Beitrag kann nicht erstattet werden.';
}
if (!refundForm.reason) {
refundErrors.reason = 'Bitte wähle einen Grund aus.';
} else if (selectedRefundReason.value?.requiresNote && !refundForm.reasonNote.trim()) {
refundErrors.reasonNote = 'Bitte erläutere den Grund.';
}
return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote;
}
async function execRefund() {
if (!validateRefund() || refundSaving.value) {
return;
}
refundSaving.value = true;
try {
const data = await request('/api/v1/participant-refund/' + showParticipant.value.identifier + '/release', {
method: "POST",
body: {
amount: refundForm.amount,
reason: refundForm.reason,
reasonNote: refundForm.reasonNote,
},
});
if (data?.status === 'success') {
toast.success(data.message);
// Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
showParticipant.value.refund = data.refund;
openRefundDialogSwitch.value = false;
} else {
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
}
} finally {
refundSaving.value = false;
}
}
async function execCancelRefund(participant) {
const data = await request('/api/v1/participant-refund/' + participant.refund.token + '/cancel', {
method: "POST",
});
if (data?.status === 'success') {
toast.success(data.message);
participant.refund = null;
} else {
toast.error(data?.message ?? 'Die Erstattung konnte nicht abgebrochen werden.');
}
}
async function downloadRefundDocument(participant) {
const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
if (!ok) {
toast.error('Der Beleg konnte nicht erstellt werden.');
}
}
const mailToType = ref('') const mailToType = ref('')
const recipientIdentifier = ref('') const recipientIdentifier = ref('')
@@ -395,6 +506,27 @@ function mailToGroup(groupKey) {
<span class="link">E-Mail senden</span> | <span class="link">E-Mail senden</span> |
<span @click="openCancelParticipationDialog(participant)" v-if="!participant.unregistered" class="link" style="color: #da7070;">Abmelden</span> <span @click="openCancelParticipationDialog(participant)" v-if="!participant.unregistered" class="link" style="color: #da7070;">Abmelden</span>
<span v-else class="link" @click="execResignonParticipant(participant)" style="color: #3cb62e;">Wieder anmelden</span> <span v-else class="link" @click="execResignonParticipant(participant)" style="color: #3cb62e;">Wieder anmelden</span>
<!-- Erstattung: erst der Einstieg, danach der Zustand des Vorgangs. -->
<template v-if="participant.unregistered">
<span
v-if="!participant.refund && Number(participant.amountPaidValue ?? 0) > 0"
class="link"
style="color: #3cb62e;"
@click="openRefundDialog(participant)"
> | Beitrag erstatten</span>
<template v-else-if="participant.refund?.status === 'pending'">
| <strong>Erstattung offen:</strong> {{ participant.refund.amount }},
wartet auf Bankverbindung
<span class="link" style="color: #da7070;" @click="execCancelRefund(participant)">Abbrechen</span>
</template>
<template v-else-if="participant.refund?.status === 'accepted'">
| <strong>Erstattet:</strong> {{ participant.refund.amount }}
<span class="link" @click="downloadRefundDocument(participant)">Beleg</span>
</template>
</template>
</td> </td>
</tr> </tr>
</template> </template>
@@ -460,6 +592,48 @@ function mailToGroup(groupKey) {
<button class="button" @click="execPartialPayment()">Teilbetrag buchen</button> <button class="button" @click="execPartialPayment()">Teilbetrag buchen</button>
</Modal> </Modal>
<Modal
:show="openRefundDialogSwitch"
title="Beitrag erstatten"
width="480px"
@close="openRefundDialogSwitch = false"
>
<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.
</p>
<div class="refund-field">
<label for="refund_amount">Zu erstattender Betrag</label>
<div>
<AmountInput id="refund_amount" v-model="refundForm.amount" style="width: 100px !important;" /> Euro
</div>
<ErrorText :message="refundErrors.amount" />
</div>
<div class="refund-field">
<label for="refund_reason">Grund</label>
<select id="refund_reason" v-model="refundForm.reason" class="form-input">
<option value="">Bitte auswählen </option>
<option v-for="reason in refundReasons" :key="reason.value" :value="reason.value">
{{ reason.label }}
</option>
</select>
<ErrorText :message="refundErrors.reason" />
</div>
<div v-if="selectedRefundReason?.requiresNote" class="refund-field">
<label for="refund_reason_note">Erläuterung</label>
<textarea id="refund_reason_note" v-model="refundForm.reasonNote" class="form-input" rows="3"></textarea>
<ErrorText :message="refundErrors.reasonNote" />
</div>
<button class="button" :disabled="refundSaving" @click="execRefund()">
{{ refundSaving ? 'Wird freigegeben' : 'Erstattung freigeben' }}
</button>
</Modal>
<FullScreenModal <FullScreenModal
:show="mailCompose" :show="mailCompose"
title="E-Mail senden" title="E-Mail senden"
@@ -473,6 +647,28 @@ function mailToGroup(groupKey) {
</template> </template>
<style scoped> <style scoped>
.refund-intro {
margin-bottom: 16px;
font-size: 0.9rem;
color: #4b5563;
}
.refund-field {
margin-bottom: 14px;
}
.refund-field label {
display: block;
margin-bottom: 4px;
font-size: 0.9rem;
color: #4b5563;
}
.refund-field select,
.refund-field textarea {
width: 100%;
}
.participants-table { .participants-table {
width: 95%; width: 95%;
margin: 20px auto; margin: 20px auto;
@@ -0,0 +1,121 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Support\Facades\Mail;
/**
* Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung.
*
* Läuft ohne Login -- der Token aus der Mail ist die Autorisierung, dasselbe Modell wie bei
* /print-girocode/{identifier}. Betrag und Grund stehen fest und werden hier nicht angefasst: sie kommen
* aus der Freigabe der Aktionsleitung.
*
* Danach ist der Vorgang festgeschrieben; der Beleg geht mit der Bestätigungsmail raus.
*/
class AcceptRefundCommand
{
/**
* Wortlaut für jeden Fall, in dem der Link nicht (mehr) zu einem offenen Vorgang führt.
*
* Bewusst ein und derselbe Text für „Token unbekannt" und „abgebrochen": eine abgebrochene Freigabe
* soll sich verhalten, als hätte es sie nie gegeben.
*/
public const string NO_OPEN_REFUND = 'Zu deiner Anmeldung liegt keine freigegebene Rückerstattung vor. '
. 'Bitte wende dich an die Aktionsleitung.';
public function __construct(private readonly AcceptRefundRequest $request)
{
}
public function execute(): AcceptRefundResponse
{
$response = new AcceptRefundResponse();
$refund = $this->request->refund;
if ($refund === null || !$refund->isPending()) {
$response->message = $refund?->isAccepted() === true
? 'Deine Angaben liegen uns bereits vor.'
: self::NO_OPEN_REFUND;
return $response;
}
$owner = trim($this->request->accountOwner);
$iban = Iban::normalize($this->request->accountIban);
// 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) {
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
}
if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
}
if ($iban === '') {
$response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
} elseif (!Iban::isValid($iban)) {
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
}
if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.';
return $response;
}
$refund->account_owner = $owner;
$refund->account_iban = $iban;
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$refund->save();
$this->notify($refund);
$response->success = true;
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
return $response;
}
/**
* Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
*
* Scheitert die Belegerzeugung, geht die Mail trotzdem raus -- der Vorgang ist gespeichert, und die
* Aktionsleitung kann den Beleg jederzeit erneut abrufen. Ein Fehler hier darf nicht dazu führen,
* dass der Teili gar nichts hört.
*/
private function notify(ParticipantRefund $refund): void
{
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
$pdf = $document->success ? $document->pdfContent : null;
$filename = $document->success ? $document->filename : null;
$participant = $refund->participant;
Mail::to($participant->email_1)->send(new RefundAcceptedMail(
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
if ($participant->email_2 !== null) {
Mail::to($participant->email_2)->send(new RefundAcceptedMail(
participant: $participant,
refund: $refund,
pdfContent: $pdf,
pdfFilename: $filename,
));
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
use App\Models\ParticipantRefund;
class AcceptRefundRequest
{
public function __construct(
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
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,
) {
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
class AcceptRefundResponse
{
public bool $success = false;
public ?string $message = null;
/**
* Feldbezogene Fehler für das Formular. Schlüssel sind die Feldnamen des Frontends.
*
* @var array<string, string>
*/
public array $errorTypes = [];
}
@@ -0,0 +1,45 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
use App\Models\ParticipantRefund;
/**
* Bricht eine freigegebene, aber noch nicht bestätigte Erstattung ab.
*
* Danach läuft der Link des Teilis ins Leere und die Anmeldung sieht aus wie vor der Freigabe. Bewusst
* ohne Mail: der Teili soll nicht über etwas informiert werden, das für ihn nie stattgefunden hat --
* die Aktionsleitung klärt das im Zweifel direkt.
*
* Ein bereits bestätigter Vorgang ist unantastbar: dazu gibt es einen Beleg, und der Teili hat seine
* Bankverbindung im Vertrauen darauf herausgegeben.
*/
class CancelRefundCommand
{
public function __construct(private readonly CancelRefundRequest $request)
{
}
public function execute(): CancelRefundResponse
{
$response = new CancelRefundResponse();
$refund = $this->request->refund;
if (!$refund->isPending()) {
$response->message = $refund->isAccepted()
? 'Diese Erstattung wurde bereits bestätigt und kann nicht mehr abgebrochen werden.'
: 'Diese Erstattung wurde bereits abgebrochen.';
return $response;
}
$refund->status = ParticipantRefund::STATUS_CANCELLED;
$refund->cancelled_at = now();
$refund->save();
$response->success = true;
$response->message = 'Die Erstattung wurde abgebrochen.';
return $response;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
use App\Models\ParticipantRefund;
class CancelRefundRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CancelRefund;
class CancelRefundResponse
{
public bool $success = false;
public ?string $message = null;
}
@@ -0,0 +1,247 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PageText;
use App\Models\ParticipantRefund;
use App\Models\Tenant;
use App\Providers\DocumentTemplateRenderProvider;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\ValueObjects\Amount;
/**
* Erzeugt den Beleg über die erstattete Teilnahmegebühr als PDF.
*
* Wie bei der Teilnahmerechnung wird nichts gespeichert: die Belegnummer leitet sich aus Veranstaltung
* und Position des Teilis ab, der Inhalt aus dem Erstattungsvorgang. Da ein bestätigter Vorgang nicht
* mehr verändert wird, liefert ein erneuter Abruf denselben Beleg.
*
* Keine Umsatzsteuer: eine Erstattung ist keine Rechnung. Ausgewiesen wird der Betrag, den der Teili
* zurückbekommt. Eine Stornorechnung mit USt-Ausweis wäre eine eigene Dokumentart.
*/
class CreateRefundDocumentCommand
{
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
private ParticipantRefund $refund;
private EventParticipant $participant;
private Event $event;
/** Der Aussteller. Gehört zum Mandanten der Veranstaltung, nicht zum gerade aktiven. */
private ?Tenant $sender;
public function __construct(private readonly CreateRefundDocumentRequest $request)
{
$this->refund = $request->refund;
$this->participant = $request->refund->participant;
$this->event = $request->refund->event;
// Wie in CreateParticipantInvoiceCommand: `$event->tenant` liefert das Slug-Attribut, nicht die
// Relation. Der Aussteller wird live gelesen, damit eine Korrektur an Name oder Anschrift auch
// auf bestehende Veranstaltungen wirkt.
$this->sender = $this->event->tenant()->first();
}
public function execute(): CreateRefundDocumentResponse
{
$response = new CreateRefundDocumentResponse();
if ($this->event->invoice_key === null || $this->participant->invoice_sequence === null) {
$response->message = 'Für diese Anmeldung lässt sich keine Belegnummer bilden.';
return $response;
}
if (!$this->refund->isAccepted()) {
$response->message = 'Der Beleg entsteht erst, wenn die Erstattung bestätigt wurde.';
return $response;
}
$documentNumber = $this->documentNumber();
$html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)
->render($this->buildTokens($documentNumber));
$response->success = true;
$response->documentNumber = $documentNumber;
$response->filename = 'Rueckerstattung-' . $documentNumber . '.pdf';
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
return $response;
}
/**
* Dieselbe Nummer wie die Rechnung, mit angehängtem `-R`. Kein zweiter Nummernkreis: der Beleg
* gehört zu genau einer Anmeldung, und so ist auf einen Blick erkennbar, zu welcher Rechnung.
*/
private function documentNumber(): string
{
return $this->invoiceNumber() . '-R';
}
/** Die Nummer der Teilnahmerechnung -- der Beleg weist sie aus, damit die Zahlung auffindbar ist. */
private function invoiceNumber(): string
{
return sprintf(
'%s-%s',
$this->event->invoice_key,
str_pad((string) $this->participant->invoice_sequence, 4, '0', STR_PAD_LEFT)
);
}
/**
* Der Erklärungssatz aus `page_texts` -- derselbe, den der Teili auf der Bestätigungsseite gelesen
* und angekreuzt hat.
*
* Mit Rückfallwert: fehlt die Zeile in der Datenbank, soll der Beleg trotzdem entstehen. Ohne den
* 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.
*/
private function declarationText(): string
{
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
return trim((string) $text) !== ''
? (string) $text
: 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.';
}
/**
* Leistungszeitraum: bei eintägigen Veranstaltungen nur ein Datum, sonst der Zeitraum.
*/
private function servicePeriod(): string
{
$start = $this->event->start_date;
$end = $this->event->end_date;
if ($start === null) {
return '';
}
if ($end === null || $start->isSameDay($end)) {
return $start->format('d.m.Y');
}
return sprintf('%s %s', $start->format('d.m.Y'), $end->format('d.m.Y'));
}
/**
* @return array<string, string>
*/
private function buildTokens(string $documentNumber): array
{
$participant = $this->participant;
$sender = $this->sender;
$refund = $this->refund;
return [
'document_title' => 'Rückerstattung ' . $documentNumber,
'document_number' => $documentNumber,
// Belegdatum ist der Tag, an dem der Teili bestätigt hat -- da stand der Vorgang fest.
'document_date' => $refund->accepted_at?->format('d.m.Y') ?? '',
'event_name' => (string) $this->event->name,
'service_period' => $this->servicePeriod(),
'unregistered_at' => $participant->unregistered_at?->format('d.m.Y') ?? '',
'sender_name' => $sender?->invoiceSenderName() ?? '',
'sender_address_1' => (string) $sender?->address_1,
'sender_address_2' => (string) $sender?->address_2,
'sender_address_3' => (string) $sender?->address_3,
'sender_postcode' => (string) $sender?->postcode,
'sender_city' => (string) $sender?->city,
'sender_email' => (string) $sender?->email,
'sender_phone' => (string) $sender?->phone,
'sender_tax_number' => (string) $sender?->tax_number,
'sender_vat_id' => (string) $sender?->vat_id,
'recipient_name' => $participant->getOfficialName(),
'recipient_address_1' => (string) $participant->address_1,
'recipient_address_2' => (string) $participant->address_2,
'recipient_postcode' => (string) $participant->postcode,
'recipient_city' => (string) $participant->city,
'paid_amount' => $this->money($participant->amount_paid?->getAmount() ?? 0.0),
'invoice_number' => $this->invoiceNumber(),
'refund_amount' => $this->money($refund->amount?->getAmount() ?? 0.0),
'refund_reason' => $refund->reasonLabel(),
'refund_reason_text' => $refund->reasonText(),
'account_owner' => (string) $refund->account_owner,
'account_iban' => $this->formatIban((string) $refund->account_iban),
'declaration_text' => $this->declarationText(),
'details_table' => $this->renderDetails(),
];
}
/**
* Der generierte Block: wer erklärt, worauf sich die Erstattung bezieht, warum, und auf welches
* Konto sie geht.
*
* Alles, was die Person erklärt, steht in dieser einen Tabelle -- auch die Begründung, die früher als
* Fließtext darunter hing. Was daneben steht (Anschrift im Briefkopf, Veranstaltung im Betreff),
* beschreibt den Vorgang, gehört aber nicht zur Erklärung selbst.
*/
private function renderDetails(): string
{
$refund = $this->refund;
$participant = $this->participant;
$rows = [
// Der Name steht voran: die Tabelle trägt alles, was die Person erklärt, und die Anschrift
// allein im Briefkopf würde den Bezug lösen, sobald der Beleg als Anlage hinter einem
// Deckblatt liegt. Kontoinhaber*in weiter unten kann eine andere Person sein -- etwa ein
// Elternteil.
['Name', e($participant->getOfficialName())],
// Der gezahlte Beitrag ist die Bezugsgröße. Ohne ihn lässt sich bei einer Teilerstattung
// nicht erkennen, warum nur ein Teil zurückgeht -- und die Zusicherung „ich habe den Betrag
// beglichen" bliebe unbelegt, obwohl mareike ihn kennt.
['Gezahlter Teilnahmebeitrag', $this->money($participant->amount_paid?->getAmount() ?? 0.0)],
['Rechnung', e($this->invoiceNumber())],
['Erstattungsbetrag', $this->money($refund->amount?->getAmount() ?? 0.0)],
['Grund', e($refund->reasonLabel())],
];
// Bei einem Freitext-Grund ist die Begründung der Text der Aktionsleitung, sonst der des
// Katalogs. Fehlt beides, entfällt die Zeile -- eine Beschriftung ohne Wert sieht nach Fehler aus.
$reasonText = trim($refund->reasonText());
if ($reasonText !== '') {
$rows[] = ['Begründung', e($reasonText)];
}
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
$html = '';
foreach ($rows as [$key, $value]) {
$html .= sprintf(
'<tr><td class="detail-key">%s</td><td class="detail-val">%s</td></tr>',
e($key),
$value
);
}
return '<table class="detail-table">' . $html . '</table>';
}
/** IBAN in Vierergruppen -- so steht sie auf jedem Beleg und lässt sich abtippen. */
private function formatIban(string $iban): string
{
return trim(chunk_split($iban, 4, ' '));
}
private function money(float $value): string
{
return new Amount($value, 'Euro')->getFormattedAmount() . '&nbsp;&euro;';
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
use App\Models\ParticipantRefund;
class CreateRefundDocumentRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
class CreateRefundDocumentResponse
{
public bool $success = false;
public string $documentNumber = '';
public string $filename = '';
public string $pdfContent = '';
public ?string $message = null;
}
@@ -0,0 +1,134 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Enumerations\RefundReason;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Repositories\ParticipantRefundRepository;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Gibt die Erstattung eines Teilnahmebeitrags frei.
*
* Der Vorgang entsteht hier nur als Absichtserklärung: Betrag und Grund stehen fest, die Bankverbindung
* fehlt noch. Der Teili ergänzt sie über den Link in der Mail. `amount_paid` bleibt unangetastet --
* gezahlt hat er bis zur Auszahlung weiterhin, was er gezahlt hat.
*/
class ReleaseRefundCommand
{
private EventParticipant $participant;
private ParticipantRefundRepository $refunds;
public function __construct(private readonly ReleaseRefundRequest $request)
{
$this->participant = $request->participant;
$this->refunds = new ParticipantRefundRepository();
}
public function execute(): ReleaseRefundResponse
{
$response = new ReleaseRefundResponse();
$rejection = $this->reject();
if ($rejection !== null) {
$response->message = $rejection;
return $response;
}
$refund = ParticipantRefund::create([
'tenant' => $this->participant->tenant,
'event_id' => $this->participant->event_id,
'event_participant_id' => $this->participant->id,
'token' => Str::random(32),
'status' => ParticipantRefund::STATUS_PENDING,
'amount' => $this->request->amount,
'reason' => $this->request->reason,
'reason_note' => $this->reasonNote(),
'released_by' => auth()->id(),
'released_at' => now(),
]);
$this->notify($refund);
$response->success = true;
$response->refund = $refund;
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
return $response;
}
/**
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
*
* @return string|null Meldung, oder null wenn nichts dagegen spricht.
*/
private function reject(): ?string
{
if ($this->participant->unregistered_at === null) {
return 'Eine Erstattung ist nur für abgemeldete Teilis möglich.';
}
if ($this->refunds->openFor($this->participant) !== null) {
return 'Für diese Anmeldung läuft bereits eine Erstattung.';
}
$amount = $this->request->amount->getAmount();
if ($amount <= 0) {
return 'Der Erstattungsbetrag muss größer als 0 sein.';
}
// Mehr zurückgeben als eingegangen ist wäre keine Erstattung mehr. Die halbe Cent-Toleranz
// fängt die Rundung des gespeicherten Floats ab.
$paid = $this->participant->amount_paid?->getAmount() ?? 0.0;
if ($amount > $paid + 0.005) {
return 'Der Erstattungsbetrag darf den gezahlten Beitrag nicht übersteigen.';
}
$reason = RefundReason::find($this->request->reason);
if ($reason === null) {
return 'Bitte wähle einen Erstattungsgrund aus.';
}
if ($reason->requires_note && trim((string) $this->request->reasonNote) === '') {
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
}
return null;
}
/** Der Freitext gehört nur zu Gründen, die ihn verlangen -- sonst stünde er ungenutzt in der DB. */
private function reasonNote(): ?string
{
$reason = RefundReason::find($this->request->reason);
if ($reason === null || !$reason->requires_note) {
return null;
}
return trim((string) $this->request->reasonNote);
}
/**
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung
* (siehe SetParticipationStateCommand).
*/
private function notify(ParticipantRefund $refund): void
{
Mail::to($this->participant->email_1)->send(new RefundReleasedMail(
participant: $this->participant,
refund: $refund,
));
if ($this->participant->email_2 !== null) {
Mail::to($this->participant->email_2)->send(new RefundReleasedMail(
participant: $this->participant,
refund: $refund,
));
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
class ReleaseRefundRequest
{
public function __construct(
public readonly EventParticipant $participant,
public readonly Amount $amount,
public readonly string $reason,
public readonly ?string $reasonNote = null,
) {
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
use App\Models\ParticipantRefund;
class ReleaseRefundResponse
{
public bool $success = false;
public ?ParticipantRefund $refund = null;
public ?string $message = null;
}
@@ -0,0 +1,37 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Öffentlich erreichbar -- der Token aus der Mail ist die Autorisierung.
*/
class AcceptRefundController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
$acceptRequest = new AcceptRefundRequest(
refund: $refund,
accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'),
);
$response = new AcceptRefundCommand($acceptRequest)->execute();
// Immer Status 200: der HttpClient des Frontends verwirft Antworten mit Fehlerstatus, die
// Feldfehler kämen dort nie an (siehe resources/js/components/HttpClient.js).
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'error_types' => $response->errorTypes,
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundCommand;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CancelRefundController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Token ist hier keine Berechtigung: abbrechen darf nur, wer die Veranstaltung auch
// verwalten kann. `getById()` prüft genau das.
if ($refund === null || $this->events->getById($refund->event_id) === null) {
abort(403, 'Zugriff verweigert.');
}
$response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Scopes\CommonController;
use Illuminate\Http\Response;
class RefundDocumentController extends CommonController
{
public function __invoke(string $refundToken): Response
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Beleg enthält die Bankverbindung -- er gehört der Aktionsleitung, nicht dem Token.
if ($refund === null || $this->events->getById($refund->event_id) === null) {
abort(403, 'Zugriff verweigert.');
}
$documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
if (!$documentResponse->success) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
}
return response($documentResponse->pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $documentResponse->filename . '"',
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Models\ParticipantRefund;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Inertia\Response;
/**
* Die öffentliche Seite, auf der der Teili seine Bankverbindung hinterlegt.
*
* Liefert ausschließlich Anzeigedaten -- niemals die bereits erfasste Bankverbindung: der Token wandert
* durch ein Postfach, und was einmal eingetragen ist, muss von dort nicht wieder herauslesbar sein.
*/
class RefundPageController extends CommonController
{
public function __invoke(string $refundToken): Response
{
$refund = $this->participantRefunds->getByToken($refundToken);
return new InertiaProvider('ParticipantRefund/RefundPage', $this->props($refund))->render();
}
/**
* @return array<string, mixed>
*/
private function props(?ParticipantRefund $refund): array
{
// Unbekannt und abgebrochen sind für den Teili derselbe Zustand: es gibt nichts zu tun.
if ($refund === null || $refund->status === ParticipantRefund::STATUS_CANCELLED) {
return [
'state' => 'unavailable',
'message' => AcceptRefundCommand::NO_OPEN_REFUND,
];
}
$participant = $refund->participant;
$event = $refund->event;
$common = [
'token' => $refund->token,
'name' => $participant->getNicename(),
'eventTitle' => $event->name,
'eventEmail' => $event->email,
'amount' => $refund->amount?->toString() ?? '0,00 Euro',
'reason' => $refund->reasonLabel(),
'reasonNote' => $refund->reason_note,
];
if ($refund->isAccepted()) {
return array_merge($common, [
'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'),
]);
}
return array_merge($common, ['state' => 'open']);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Scopes\CommonController;
use App\Support\Text;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReleaseRefundController extends CommonController
{
public function __invoke(string $participantIdentifier, Request $request): JsonResponse
{
$participant = $this->eventParticipants->getByIdentifier($participantIdentifier, $this->events);
if ($participant === null) {
abort(403, 'Zugriff verweigert.');
}
$refundRequest = new ReleaseRefundRequest(
participant: $participant,
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
reason: (string) $request->input('reason'),
reasonNote: Text::nullIfBlank($request->input('reasonNote')),
);
$response = new ReleaseRefundCommand($refundRequest)->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'refund' => $response->refund?->toResource()->toArray($request),
]);
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Domains\ParticipantRefund;
/**
* Katalog der Platzhalter, die in der Vorlage des Erstattungsbelegs zur Verfügung stehen.
*
* Dient wie {@see \App\Domains\ParticipantInvoice\ParticipantInvoiceTokens} zwei Zwecken: der
* Platzhalter-Liste in der Vorlagen-Verwaltung und den Beispieldaten für die Vorschau. Die echten Werte
* setzt
* {@see \App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand}
* zusammen -- dass beide Seiten dieselben Namen kennen, sichert ein Test ab.
*/
final class ParticipantRefundTokens
{
/**
* @return array<string, array{label: string, tokens: array<string, array{description: string, sample: string}>}>
*/
public static function groups(): array
{
return [
'document' => [
'label' => 'Dokument',
'tokens' => [
'document_title' => ['description' => 'Titel des PDF-Dokuments', 'sample' => 'Rückerstattung WM-V-20260701-0005-R'],
'document_number' => ['description' => 'Belegnummer', 'sample' => 'WM-V-20260701-0005-R'],
'document_date' => ['description' => 'Datum der Bestätigung durch die teilnehmende Person', 'sample' => '18.06.2026'],
'event_name' => ['description' => 'Name der Veranstaltung', 'sample' => 'Sommerlager'],
'service_period' => ['description' => 'Zeitraum der Veranstaltung', 'sample' => '16.07.2026 20.07.2026'],
'unregistered_at' => ['description' => 'Datum der Abmeldung', 'sample' => '12.06.2026'],
],
],
'sender' => [
'label' => 'Absender',
'tokens' => [
'sender_name' => ['description' => 'Absender (Standard: Name des Mandanten)', 'sample' => 'BdP Landesverband Sachsen e.V.'],
'sender_address_1' => ['description' => 'Straße und Hausnummer', 'sample' => 'Musterweg 1'],
'sender_address_2' => ['description' => 'Adresszusatz, z. B. „c/o …"', 'sample' => 'c/o Mustermensch'],
'sender_address_3' => ['description' => 'Weiterer Adresszusatz', 'sample' => ''],
'sender_postcode' => ['description' => 'Postleitzahl', 'sample' => '01623'],
'sender_city' => ['description' => 'Ort', 'sample' => 'Lommatzsch'],
'sender_email' => ['description' => 'E-Mail-Adresse', 'sample' => 'kontakt@example.com'],
'sender_phone' => ['description' => 'Telefonnummer', 'sample' => '0351 1234567'],
'sender_tax_number' => ['description' => 'Steuernummer', 'sample' => '201/123/45678'],
'sender_vat_id' => ['description' => 'USt-IdNr.', 'sample' => ''],
],
],
'recipient' => [
'label' => 'Empfänger',
'tokens' => [
'recipient_name' => ['description' => 'Name der teilnehmenden Person', 'sample' => 'Mika Muster'],
'recipient_address_1' => ['description' => 'Straße und Hausnummer', 'sample' => 'Beispielstraße 3'],
'recipient_address_2' => ['description' => 'Adresszusatz', 'sample' => ''],
'recipient_postcode' => ['description' => 'Postleitzahl', 'sample' => '11111'],
'recipient_city' => ['description' => 'Ort', 'sample' => 'Beispielstadt'],
],
],
'refund' => [
'label' => 'Erstattung',
'tokens' => [
'paid_amount' => ['description' => 'Bereits gezahlter Teilnahmebeitrag', 'sample' => '300,00 €'],
'invoice_number' => ['description' => 'Nummer der Teilnahmerechnung', 'sample' => 'WM-V-20260701-0005'],
'refund_amount' => ['description' => 'Erstattungsbetrag', 'sample' => '220,00 €'],
'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.'],
'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.'],
],
],
'body' => [
'label' => 'Beleginhalt (generiert)',
'tokens' => [
'details_table' => ['description' => 'Tabelle mit Name, gezahltem Beitrag, Rechnung, Erstattungsbetrag, Grund, Begründung und Bankverbindung', 'sample' => self::sampleDetails()],
],
],
];
}
/**
* Beispielwerte für die Vorschau.
*
* @return array<string, string>
*/
public static function sample(): array
{
$sample = [];
foreach (self::groups() as $group) {
foreach ($group['tokens'] as $name => $token) {
$sample[$name] = $token['sample'];
}
}
return $sample;
}
/** @return array<int, string> */
public static function names(): array
{
return array_keys(self::sample());
}
private static function sampleDetails(): string
{
return '<table class="detail-table">'
. '<tr><td class="detail-key">Name</td><td class="detail-val">Mika Muster</td></tr>'
. '<tr><td class="detail-key">Gezahlter Teilnahmebeitrag</td><td class="detail-val">300,00&nbsp;&euro;</td></tr>'
. '<tr><td class="detail-key">Rechnung</td><td class="detail-val">WM-V-20260701-0005</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">Begr&uuml;ndung</td><td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</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>'
. '</table>';
}
}
@@ -0,0 +1,24 @@
<?php
use App\Domains\ParticipantRefund\Controllers\AcceptRefundController;
use App\Domains\ParticipantRefund\Controllers\CancelRefundController;
use App\Domains\ParticipantRefund\Controllers\RefundDocumentController;
use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::prefix('api/v1')
->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('participant-refund')->group(function () {
// Der Teili bestätigt über den Token aus seiner Mail -- ohne Login.
Route::post('{refundToken}/accept', AcceptRefundController::class);
Route::middleware(['auth'])->group(function () {
Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
Route::post('{refundToken}/cancel', CancelRefundController::class);
Route::get('{refundToken}/document', RefundDocumentController::class);
});
});
});
});
@@ -0,0 +1,10 @@
<?php
use App\Domains\ParticipantRefund\Controllers\RefundPageController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
// Bewusst ohne `auth`: die Seite ist der Link aus der Mail an den Teili.
Route::get('/rueckerstattung/{refundToken}', RefundPageController::class);
});
@@ -0,0 +1,249 @@
<script setup>
import {reactive, ref} from 'vue'
import {toast} from 'vue3-toastify'
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
import ErrorText from '../../../Views/Components/ErrorText.vue'
import IbanInput from '../../../Views/Components/IbanInput.vue'
import TextResource from '../../../Views/Components/TextResource.vue'
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
const {request} = useAjax()
/**
* `state` steuert die ganze Seite:
* open -- Formular für die Bankverbindung
* accepted -- Angaben liegen vor, nichts mehr zu tun
* unavailable -- Token unbekannt oder Erstattung abgebrochen
*/
const props = defineProps({
state: String,
message: String,
token: String,
name: String,
eventTitle: String,
eventEmail: String,
amount: String,
reason: String,
reasonNote: String,
acceptedAt: String,
})
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false})
const errors = reactive({accountOwner: '', accountIban: '', declaration: ''})
const saving = ref(false)
function validate() {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
return !errors.accountOwner && !errors.accountIban && !errors.declaration
}
async function submit() {
if (!validate() || saving.value) return
saving.value = true
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body: {
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
},
})
if (!response) {
toast.error('Deine Angaben konnten nicht gespeichert werden. Bitte versuche es später erneut.')
return
}
// Feldbezogene Fehler kommen mit Status 200 als `error_types` zurück -- der HttpClient des
// Projekts verwirft Antworten mit Fehlerstatus.
if (response.status !== 'success') {
Object.keys(response.error_types ?? {}).forEach((key) => {
if (key in errors) errors[key] = response.error_types[key]
})
toast.error(response.message ?? 'Bitte prüfe deine Angaben.')
return
}
toast.success(response.message)
state.value = 'accepted'
} finally {
saving.value = false
}
}
</script>
<template>
<AppLayout title="Rückerstattung">
<shadowed-box style="max-width: 640px; margin: 60px auto; padding: 24px;">
<template v-if="state === 'unavailable'">
<h2>Rückerstattung</h2>
<p class="hint">{{ props.message }}</p>
</template>
<template v-else>
<h2>Rückerstattung deines Teilnahmebeitrags</h2>
<p>
Hallo {{ props.name }}, für deine Abmeldung von der Veranstaltung
<strong>{{ props.eventTitle }}</strong> wurde eine Rückerstattung freigegeben.
</p>
<table class="summary">
<tr>
<th>Betrag</th>
<td><strong>{{ props.amount }}</strong></td>
</tr>
<tr>
<th>Grund</th>
<td>{{ props.reason }}</td>
</tr>
<tr v-if="props.reasonNote">
<th>Anmerkung</th>
<td>{{ props.reasonNote }}</td>
</tr>
</table>
<template v-if="state === 'accepted'">
<p class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
</p>
</template>
<template v-else>
<p class="hint">
Der Betrag steht fest und lässt sich hier nicht ändern. Hast du dazu Fragen, wende
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<h3>Auf welches Konto sollen wir überweisen?</h3>
<form @submit.prevent="submit">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
<!--
Die Erklärung, die anschließend auf dem Eigenbeleg steht. Sie muss hier
gelesen und angekreuzt werden -- sonst schriebe der Beleg dem Teili eine
Zusicherung zu, die er nie abgegeben hat.
-->
<div class="declaration">
<input
id="refund-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button class="button" type="submit" :disabled="saving || !form.declarationAccepted">
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</form>
</template>
</template>
</shadowed-box>
</AppLayout>
</template>
<style scoped>
h2 {
margin-bottom: 16px;
}
h3 {
margin: 24px 0 12px;
}
.hint {
margin: 16px 0;
padding: 10px 12px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.9rem;
color: #4b5563;
}
.summary {
border-collapse: collapse;
margin: 20px 0;
}
.summary th {
text-align: left;
padding: 6px 24px 6px 0;
color: #555;
font-weight: normal;
white-space: nowrap;
}
.summary td {
padding: 6px 0;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 0.9rem;
color: #4b5563;
}
.field .form-input {
width: 100%;
}
.declaration {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 20px 0 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.declaration input {
margin-top: 3px;
flex-shrink: 0;
}
.declaration :deep(label) {
cursor: pointer;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
/**
* Gründe für die Erstattung eines Teilnahmebeitrags -- DB-gestützt (analog {@see TaxExemptionReason}),
* damit Label und der auf dem Beleg ausgewiesene Text zentral pflegbar sind.
*
* @property string $slug
* @property string $name
* @property string|null $document_text
* @property bool $requires_note
* @property int $sort_order
*/
class RefundReason extends CommonModel
{
public const string SICKNESS = 'sickness';
public const string EVENT_CANCELLED = 'event_cancelled';
public const string OTHER = 'other';
protected $table = 'refund_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 „Grund" steht. Bei einem Grund, der einen Freitext verlangt,
* ist der hinterlegte Text der 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();
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Mail\ParticipantRefundMails;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Support\Iban;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Bestätigt dem Teili die erfassten Angaben und liefert den Beleg als PDF mit.
*
* Der Beleg wird nicht hier erzeugt, sondern übergeben: er entsteht einmal in
* {@see \App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand} und geht an beide
* Empfänger. Scheitert die Erzeugung, geht die Mail ohne Anhang raus statt gar nicht.
*/
class RefundAcceptedMail extends Mailable
{
public function __construct(
private EventParticipant $participant,
private ParticipantRefund $refund,
private ?string $pdfContent = null,
private ?string $pdfFilename = null,
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: sprintf(
'Deine Angaben zur Rückerstattung für %s',
$this->participant->event()->first()->name
),
);
}
public function content(): Content
{
$event = $this->participant->event()->first();
return new Content(
view: 'emails.events.refund_accepted',
with: [
'name' => $this->participant->getNicename(),
'eventTitle' => $event->name,
'eventEmail' => $event->email,
'amount' => $this->refund->amount?->toString() ?? '0,00 Euro',
'reason' => $this->refund->reasonLabel(),
'accountOwner' => $this->refund->account_owner,
'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null,
],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
if ($this->pdfContent === null) {
return [];
}
return [
Attachment::fromData(fn (): string => $this->pdfContent, $this->pdfFilename ?? 'Rueckerstattung.pdf')
->withMime('application/pdf'),
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Mail\ParticipantRefundMails;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Fordert den Teili auf, seine Bankverbindung für die freigegebene Erstattung zu hinterlegen.
*/
class RefundReleasedMail extends Mailable
{
public function __construct(
private EventParticipant $participant,
private ParticipantRefund $refund,
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: sprintf(
'Rückerstattung deines Beitrags für die Veranstaltung %s',
$this->participant->event()->first()->name
),
);
}
public function content(): Content
{
$event = $this->participant->event()->first();
return new Content(
view: 'emails.events.refund_released',
with: [
'name' => $this->participant->getNicename(),
'eventTitle' => $event->name,
'eventEmail' => $event->email,
'amount' => $this->refund->amount?->toString() ?? '0,00 Euro',
'reason' => $this->refund->reasonLabel(),
'reasonNote' => $this->refund->reason_note,
// Absolute URL: der Link muss aus jedem Postfach heraus funktionieren.
'link' => url('/rueckerstattung/' . $this->refund->token),
],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [];
}
}
+3 -1
View File
@@ -19,13 +19,15 @@ class DocumentTemplate extends CommonModel
{ {
public const string TYPE_PARTICIPANT_INVOICE = 'participant_invoice'; public const string TYPE_PARTICIPANT_INVOICE = 'participant_invoice';
public const string TYPE_PARTICIPANT_REFUND = 'participant_refund';
/** Seitengerüst -- enthält die `{block:...}`-Platzhalter und bestimmt damit die Anordnung. */ /** Seitengerüst -- enthält die `{block:...}`-Platzhalter und bestimmt damit die Anordnung. */
public const string BLOCK_LAYOUT = 'layout'; public const string BLOCK_LAYOUT = 'layout';
/** CSS des Dokuments. */ /** CSS des Dokuments. */
public const string BLOCK_STYLE = 'style'; public const string BLOCK_STYLE = 'style';
/** Der generierte Teil (Positionstabelle, Summen, Schlusssatz) -- nicht editierbar. */ /** Der Inhaltsblock. Bei der Rechnung generiert und gesperrt, beim Erstattungsbeleg pflegbar. */
public const string BLOCK_BODY = 'body'; public const string BLOCK_BODY = 'body';
protected $table = 'document_templates'; protected $table = 'document_templates';
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Models;
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
use App\Domains\ParticipantRefund\ParticipantRefundTokens;
/**
* Was die Vorlagen-Verwaltung über eine Dokumentart wissen muss: wie sie heißt, welche Platzhalter es
* gibt und wie die Blöcke im Formular beschriftet sind.
*
* Die eine Stelle, an der eine neue Dokumentart eingetragen wird -- Controller und Formular lesen von
* hier und kennen selbst keine Dokumentart mehr.
*/
final class DocumentTypeCatalog
{
/** Blöcke, die Layout-HTML bzw. CSS enthalten und deshalb im Quelltext-Editor gepflegt werden. */
public const array SOURCE_BLOCKS = [
DocumentTemplate::BLOCK_LAYOUT,
DocumentTemplate::BLOCK_STYLE,
];
/**
* Die Beschriftungen der Blöcke, die jede Dokumentart hat -- Seitengerüst, Gestaltung und der
* Briefkopf des Verbands.
*
* @var array<string, string>
*/
private const array COMMON_BLOCK_LABELS = [
DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst',
DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)',
'header_sender_return' => 'Rücksendezeile',
'header_recipient' => 'Empfängeranschrift',
'emblem' => 'Emblem',
'logo' => 'Logo',
'sender_data' => 'Absenderangaben',
'subject' => 'Betreff und Referenzdaten',
];
/**
* @return array<string, array{label: string, tokens: class-string, blockLabels: array<string, string>}>
*/
public static function all(): array
{
return [
DocumentTemplate::TYPE_PARTICIPANT_INVOICE => [
'label' => 'Teilnahmerechnung',
'tokens' => ParticipantInvoiceTokens::class,
'blockLabels' => self::COMMON_BLOCK_LABELS + [
DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt',
'footer' => 'Grußformel und Fußnote',
],
],
DocumentTemplate::TYPE_PARTICIPANT_REFUND => [
'label' => 'Erstattungsbeleg',
'tokens' => ParticipantRefundTokens::class,
'blockLabels' => self::COMMON_BLOCK_LABELS + [
DocumentTemplate::BLOCK_BODY => 'Erklärung und Angaben zur Erstattung',
'footer' => 'Fußbereich',
],
],
];
}
public static function has(string $documentType): bool
{
return array_key_exists($documentType, self::all());
}
/** Die Dokumentart, oder null wenn sie nicht im Katalog steht. */
public static function get(string $documentType): ?array
{
return self::all()[$documentType] ?? null;
}
public static function default(): string
{
return DocumentTemplate::TYPE_PARTICIPANT_INVOICE;
}
public static function blockLabel(string $documentType, string $block): string
{
return self::get($documentType)['blockLabels'][$block] ?? $block;
}
/**
* Platzhalter-Gruppen der Dokumentart -- für die Liste im Formular.
*
* @return array<string, array<string, mixed>>
*/
public static function tokenGroups(string $documentType): array
{
$tokens = self::get($documentType)['tokens'] ?? null;
return $tokens === null ? [] : $tokens::groups();
}
/**
* Beispielwerte der Dokumentart -- für die Vorschau.
*
* @return array<string, string>
*/
public static function sampleTokens(string $documentType): array
{
$tokens = self::get($documentType)['tokens'] ?? null;
return $tokens === null ? [] : $tokens::sample();
}
/**
* Die Auswahl für den Umschalter im Formular.
*
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
$options = [];
foreach (self::all() as $type => $definition) {
$options[] = ['value' => $type, 'label' => $definition['label']];
}
return $options;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Models;
use App\Casts\AmountCast;
use App\Enumerations\RefundReason;
use App\Scopes\InstancedModel;
use App\ValueObjects\Amount;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Ein Erstattungsvorgang zu einer Anmeldung.
*
* @property int $id
* @property string $tenant
* @property int $event_id
* @property int $event_participant_id
* @property string $token
* @property string $status
* @property Amount|null $amount
* @property string|null $reason
* @property string|null $reason_note
* @property string|null $account_owner
* @property string|null $account_iban
* @property int|null $released_by
* @property \Illuminate\Support\Carbon|null $released_at
* @property \Illuminate\Support\Carbon|null $accepted_at
* @property \Illuminate\Support\Carbon|null $cancelled_at
*/
class ParticipantRefund extends InstancedModel
{
/** Freigegeben, wartet auf die Bankverbindung des Teilis. */
public const string STATUS_PENDING = 'pending';
/** Der Teili hat bestätigt, der Beleg ist erstellt. Ab hier unveränderlich. */
public const string STATUS_ACCEPTED = 'accepted';
/** Von der Aktionsleitung abgebrochen, bevor der Teili bestätigt hat. */
public const string STATUS_CANCELLED = 'cancelled';
protected $table = 'participant_refunds';
protected $fillable = [
'tenant',
'event_id',
'event_participant_id',
'token',
'status',
'amount',
'reason',
'reason_note',
'account_owner',
'account_iban',
'released_by',
'released_at',
'accepted_at',
'cancelled_at',
];
protected $casts = [
'amount' => AmountCast::class,
'released_at' => 'datetime',
'accepted_at' => 'datetime',
'cancelled_at' => 'datetime',
];
public function participant(): BelongsTo
{
return $this->belongsTo(EventParticipant::class, 'event_participant_id');
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/**
* Der Grund als Stammdatensatz. Nicht `reason()`, weil das die Spalte `reason` verdecken würde.
*/
public function reasonRelation(): BelongsTo
{
return $this->belongsTo(RefundReason::class, 'reason', 'slug');
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isAccepted(): bool
{
return $this->status === self::STATUS_ACCEPTED;
}
/** Der auf dem Beleg auszuweisende Grundtext -- bei Freitext-Gründen der Text der Aktionsleitung. */
public function reasonText(): string
{
return $this->reasonRelation()->first()?->documentText($this->reason_note) ?? '';
}
public function reasonLabel(): string
{
return (string) ($this->reasonRelation()->first()?->name ?? '');
}
}
+6
View File
@@ -4,6 +4,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\UserRole; use App\Enumerations\UserRole;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\Tenant; use App\Models\Tenant;
@@ -189,6 +190,11 @@ class GlobalDataProvider {
return $activeUsers; return $activeUsers;
} }
/** Auswahl der Erstattungsgründe für den Dialog „Beitrag erstatten". */
public function getRefundReasons() : JsonResponse {
return response()->json(RefundReason::options());
}
public function getEventSettingData(Request $request) : JsonResponse { public function getEventSettingData(Request $request) : JsonResponse {
return response()->json( return response()->json(
[ [
@@ -0,0 +1,41 @@
<?php
namespace App\Repositories;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
class ParticipantRefundRepository
{
/** Der offene Vorgang einer Anmeldung -- es kann höchstens einen geben. */
public function openFor(EventParticipant $participant): ?ParticipantRefund
{
return ParticipantRefund::where('event_participant_id', $participant->id)
->where('status', ParticipantRefund::STATUS_PENDING)
->first();
}
/**
* Der für die Anzeige maßgebliche Vorgang: der offene, sonst der zuletzt bestätigte. Abgebrochene
* Vorgänge bleiben außen vor -- für die Aktionsleitung sieht die Anmeldung danach aus wie vorher.
*/
public function currentFor(EventParticipant $participant): ?ParticipantRefund
{
return ParticipantRefund::where('event_participant_id', $participant->id)
->whereIn('status', [ParticipantRefund::STATUS_PENDING, ParticipantRefund::STATUS_ACCEPTED])
->orderByDesc('id')
->first();
}
/**
* Der Vorgang zu einem öffentlichen Link.
*
* Der Token ist die einzige Autorisierung -- dasselbe Modell wie bei /print-girocode/{identifier}.
* Der Mandant kommt über den globalen SiteScope aus dem Host der Anfrage: ein Token einer anderen
* Instanz findet hier nichts.
*/
public function getByToken(string $token): ?ParticipantRefund
{
return ParticipantRefund::where('token', $token)->first();
}
}
@@ -8,6 +8,7 @@ use App\Enumerations\ParticipationType;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Repositories\ParticipantRefundRepository;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -74,6 +75,10 @@ class EventParticipantResource extends JsonResource
// Numerisch, damit das Frontend rechnen bzw. auf "kostenlos" prüfen kann -- `value` oben ist // Numerisch, damit das Frontend rechnen bzw. auf "kostenlos" prüfen kann -- `value` oben ist
// ein Amount-Objekt und serialisiert nicht. // ein Amount-Objekt und serialisiert nicht.
'amountExpectedValue' => $this->resource->amount?->getAmount() ?? 0.0, 'amountExpectedValue' => $this->resource->amount?->getAmount() ?? 0.0,
'amountPaidValue' => $this->resource->amount_paid?->getAmount() ?? 0.0,
// Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder
// der letzte abgebrochen wurde.
'refund' => $this->refund($request),
'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age, 'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age,
'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000', 'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000',
'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000', 'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000',
@@ -106,4 +111,17 @@ class EventParticipantResource extends JsonResource
] ]
); );
} }
/**
* Der für die Anzeige maßgebliche Erstattungsvorgang.
*
* Abgebrochene Vorgänge bleiben außen vor: für die Aktionsleitung soll die Anmeldung danach
* aussehen wie vor der Freigabe.
*/
private function refund($request): ?array
{
$refund = new ParticipantRefundRepository()->currentFor($this->resource);
return $refund?->toResource()->toArray($request);
}
} }
@@ -0,0 +1,37 @@
<?php
namespace App\Resources;
use App\Models\ParticipantRefund;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Der Erstattungsvorgang für das Frontend.
*
* Bewusst NICHT `$this->resource->toArray()` als Basis wie in {@see EventParticipantResource}: die
* Bankverbindung darf nur dorthin, wo sie hingehört (Beleg und Auszahlung), nicht in jede
* Teilnehmerliste. Deshalb werden die Felder hier einzeln aufgeführt.
*/
class ParticipantRefundResource extends JsonResource
{
public function __construct(ParticipantRefund $refund)
{
parent::__construct($refund);
}
public function toArray($request): array
{
return [
'token' => $this->resource->token,
'status' => $this->resource->status,
'amount' => $this->resource->amount?->toString() ?? '0,00 Euro',
'amountValue' => $this->resource->amount?->getAmount() ?? 0.0,
'reason' => $this->resource->reason,
'reasonLabel' => $this->resource->reasonLabel(),
'reasonNote' => $this->resource->reason_note,
'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
];
}
}
+3
View File
@@ -12,6 +12,7 @@ use App\Repositories\EventParticipantRepository;
use App\Repositories\EventRepository; use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository; use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository; use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
abstract class CommonController { abstract class CommonController {
@@ -24,6 +25,7 @@ abstract class CommonController {
protected InvoiceRepository $invoices; protected InvoiceRepository $invoices;
protected EventRepository $events; protected EventRepository $events;
protected EventParticipantRepository $eventParticipants; protected EventParticipantRepository $eventParticipants;
protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates; protected EstimatesRepository $estimates;
protected AdminUserRepository $adminUsers; protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants; protected AdminTenantRepository $adminTenants;
@@ -36,6 +38,7 @@ abstract class CommonController {
$this->invoices = new InvoiceRepository(); $this->invoices = new InvoiceRepository();
$this->events = new EventRepository(); $this->events = new EventRepository();
$this->eventParticipants = new EventParticipantRepository(); $this->eventParticipants = new EventParticipantRepository();
$this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository(); $this->estimates = new EstimatesRepository();
$this->adminUsers = new AdminUserRepository(); $this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository(); $this->adminTenants = new AdminTenantRepository();
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Zustandslose Helfer für IBANs.
*
* Geprüft wird nach ISO 13616 bzw. ISO 7064 (Mod 97-10): Struktur, Länge des Landes und die Prüfziffer.
* Die Prüfziffer ist der wichtige Teil -- sie fängt Zahlendreher und Tippfehler ab, die sonst erst beim
* Rückläufer der Bank auffallen würden. Auf ein Konto, dessen IBAN nur strukturell stimmt, überweist man
* im Zweifel Geld an eine fremde Person.
*/
final class Iban
{
/**
* Länge der IBAN je Ländercode. Vollständig für den SEPA-Raum; alles darüber hinaus fällt auf die
* generische Längenprüfung zurück (siehe isValid()).
*
* @var array<string, int>
*/
private const array LENGTHS = [
'AD' => 24, 'AT' => 20, 'BE' => 16, 'BG' => 22, 'CH' => 21, 'CY' => 28, 'CZ' => 24,
'DE' => 22, 'DK' => 18, 'EE' => 20, 'ES' => 24, 'FI' => 18, 'FR' => 27, 'GB' => 22,
'GI' => 23, 'GR' => 27, 'HR' => 21, 'HU' => 28, 'IE' => 22, 'IS' => 26, 'IT' => 27,
'LI' => 21, 'LT' => 20, 'LU' => 20, 'LV' => 21, 'MC' => 27, 'MT' => 31, 'NL' => 18,
'NO' => 15, 'PL' => 28, 'PT' => 25, 'RO' => 24, 'SE' => 24, 'SI' => 19, 'SK' => 24,
'SM' => 27, 'VA' => 22,
];
/** Leerzeichen raus, Großbuchstaben -- die kanonische Form, die gespeichert wird. */
public static function normalize(string $iban): string
{
return strtoupper(preg_replace('/\s+/', '', $iban) ?? '');
}
public static function isValid(string $iban): bool
{
$iban = self::normalize($iban);
if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $iban) !== 1) {
return false;
}
$expected = self::LENGTHS[substr($iban, 0, 2)] ?? null;
if ($expected !== null && strlen($iban) !== $expected) {
return false;
}
return self::checksum($iban) === 1;
}
/** In Vierergruppen -- die Schreibweise auf Belegen und Formularen. */
public static function format(string $iban): string
{
return trim(chunk_split(self::normalize($iban), 4, ' '));
}
/**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
*
* Der Rest wird stellenweise fortgeschrieben, weil die Zahl sonst jeden Integer sprengt.
*/
private static function checksum(string $iban): int
{
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
$remainder = 0;
foreach (str_split($rearranged) as $char) {
$value = ctype_digit($char) ? $char : (string) (ord($char) - 55);
foreach (str_split($value) as $digit) {
$remainder = ($remainder * 10 + (int) $digit) % 97;
}
}
return $remainder;
}
}
+4
View File
@@ -57,6 +57,10 @@
"@php artisan config:clear --ansi", "@php artisan config:clear --ansi",
"@php artisan test" "@php artisan test"
], ],
"test:coverage": [
"@php artisan config:clear --ansi",
"@php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html storage/coverage --coverage-text"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi"
@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gründe für die Erstattung eines Teilnahmebeitrags -- DB-gestützt wie die Befreiungsgründe der
* Umsatzsteuer, damit Label und der auf dem Beleg erscheinende Text zentral pflegbar sind.
*
* App-weit und ohne `tenant`-Spalte: die Gründe sind Stammdaten, mandantenspezifisch ist nur der
* einzelne Erstattungsvorgang.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('refund_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('refund_reasons')->insert([
[
'slug' => 'sickness',
'name' => 'Krankheitsbedingte Absage',
'document_text' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.',
'requires_note' => false,
'sort_order' => 10,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'event_cancelled',
'name' => 'Ausfall der Veranstaltung',
'document_text' => 'Die Veranstaltung wurde abgesagt und konnte nicht stattfinden.',
'requires_note' => false,
'sort_order' => 20,
'created_at' => now(),
'updated_at' => now(),
],
[
// Der Text entsteht erst aus dem Freitext der Aktionsleitung -- deshalb hier leer.
'slug' => 'other',
'name' => 'Sonstiger Grund',
'document_text' => null,
'requires_note' => true,
'sort_order' => 30,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('refund_reasons');
}
};
@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ein Erstattungsvorgang je Anmeldung.
*
* Der Vorgang läuft in zwei Schritten: die Aktionsleitung gibt Betrag und Grund frei (`pending`), der
* Teili ergänzt über den Token-Link seine Bankverbindung (`accepted`). Solange nicht bestätigt wurde,
* kann die Aktionsleitung abbrechen (`cancelled`) -- der Link verhält sich danach so, als hätte es nie
* eine Freigabe gegeben.
*
* `event_participants.amount_paid` bleibt in jedem Fall unangetastet: der Vorgang ist freigegeben, aber
* noch nicht ausgezahlt. Die Verrechnung kommt mit der Auszahlung.
*
* Bewusst KEIN Unique auf `event_participant_id`: abgebrochene Vorgänge bleiben als Historie stehen, und
* danach darf eine neue Freigabe entstehen. Dass es höchstens einen offenen Vorgang gibt, setzt
* ParticipantRefundRepository::openFor() durch.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('participant_refunds', function (Blueprint $table) {
$table->id();
$table->string('tenant');
$table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate();
// Der öffentliche Link. Eigener Token statt des Teilnehmer-Identifiers, damit ein Abbruch den
// Link tötet, ohne andere per Identifier erreichbare Funktionen (GiroCode) mitzunehmen.
$table->string('token', 32)->unique();
$table->string('status');
$table->float('amount', 2)->default(0);
$table->string('reason')->nullable();
$table->text('reason_note')->nullable();
$table->string('account_owner')->nullable();
$table->string('account_iban')->nullable();
$table->foreignId('released_by')->nullable()->constrained('users', 'id')->nullOnDelete();
$table->dateTime('released_at')->nullable();
$table->dateTime('accepted_at')->nullable();
$table->dateTime('cancelled_at')->nullable();
$table->timestamps();
$table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate();
$table->foreign('reason')->references('slug')->on('refund_reasons')->restrictOnDelete()->cascadeOnUpdate();
});
}
public function down(): void
{
Schema::dropIfExists('participant_refunds');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Der Erklärungssatz, den der Teili auf der Erstattungsseite bestätigt und der anschließend auf dem
* Eigenbeleg steht.
*
* Er liegt in `page_texts` und nicht in der Dokumentvorlage, damit Seite und Beleg denselben Wortlaut
* zeigen: was der Teili gelesen hat, muss wörtlich das sein, was das PDF ihm zuschreibt. Zwei getrennte
* Quellen würden früher oder später auseinanderlaufen.
*
* Als Migration und nicht als Seed-Skript, weil ohne diesen Text weder die Seite noch der Beleg
* vollständig sind -- anders als bei den Dokumentvorlagen, wo ein Release gepflegte Texte nicht
* überschreiben soll. `updateOrInsert` lässt eine bereits angepasste Fassung deshalb auch in Ruhe:
* geschrieben wird nur, was noch fehlt.
*/
return new class extends Migration {
private const string NAME = 'CONFIRMATION_PARTICIPANT_REFUND';
public function up(): void
{
$existing = DB::table('page_texts')->where('name', self::NAME)->first();
if ($existing !== null) {
return;
}
DB::table('page_texts')->insert([
'name' => self::NAME,
'content' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht '
. 'anderweitig zurückerstattet bekomme.',
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('page_texts')->where('name', self::NAME)->delete();
}
};
@@ -0,0 +1,134 @@
-- Standard-Vorlage für den Erstattungsbeleg.
--
-- Bewusst ein manuelles Skript und keine Migration: der Vorlageninhalt wird über die Admin-Oberfläche
-- gepflegt und soll von einem Release nicht überschrieben werden. Einspielen also nur bei der
-- Erstinstallation oder wenn die Vorlage bewusst zurückgesetzt werden soll.
--
-- docker exec -i mareike-mareike-db mysql -u<user> -p<pass> <db> < database/seed-scripts/participant-refund-template.sql
--
-- Setzt participant-invoice-template.sql voraus: die Bilder ({asset:emblem}, {asset:logo},
-- {asset:edge}) liegen in `document_assets` und werden von beiden Dokumentarten gemeinsam genutzt.
--
-- Der Beleg ist kein Schreiben des Verbands, sondern die Erklärung des Teilis ("Ich bitte um …", "Ich
-- versichere …") -- ein Eigenbeleg.
--
-- Briefkopf samt Anschriftenfeld ist der der Rechnung: die Anschrift der erklärenden Person steht dort
-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht,
-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil).
--
-- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili
-- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text.
DELETE FROM document_templates WHERE document_type = 'participant_refund';
INSERT INTO document_templates (document_type, block, content, sort_order, editable, created_at, updated_at) VALUES
('participant_refund', 'layout', '<img class="edge" src="{asset:edge}" alt="" />
<div class="page">
<table class="header-table">
<tr>
<td style="width: 45%; padding-top: 4mm;">
{block:header_sender_return}
{block:header_recipient}
</td>
<td style="width: 9%; vertical-align: middle; padding-top: 4mm; padding-right: 6mm;">
{block:emblem}
</td>
<td style="width: 46%; vertical-align: top; padding-top: 2mm;">
{block:logo}
{block:sender_data}
</td>
</tr>
</table>
{block:subject}
{block:body}
{block:footer}
</div>', 10, 1, NOW(), NOW()),
('participant_refund', 'style', '@page { size: A4 portrait; margin: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1a; background: white; }
.page { padding: 10mm 18mm 20mm 22mm; }
/* Kopfbereich */
.header-table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
.header-table td { vertical-align: top; padding: 0; }
.absender-rueck { font-size: 6pt; color: #555; border-bottom: 0.3pt solid #888; padding-bottom: 1.5mm; margin-bottom: 3.5mm; }
.empfaenger { font-size: 10pt; line-height: 1.65; }
.empfaenger-bold { font-weight: bold; }
.logo-img { width: 36mm; display: block; margin-bottom: 4mm; }
.emblem-img { width: 22mm; display: block; margin: 0 auto; }
.absender-block { font-size: 7.5pt; color: #1a4799; line-height: 1.7; }
.absender-name { font-weight: bold; font-size: 8pt; }
/* Betreff und Referenzdaten */
.betreff { font-size: 14pt; color: #1a4799; margin-bottom: 5mm; }
.ref-table { border-collapse: collapse; font-size: 8.5pt; margin-bottom: 7mm; }
.ref-table td { padding: 0.9mm 7mm 0.9mm 0; vertical-align: top; }
.ref-key { color: #666; white-space: nowrap; }
/* Einleitender Satz */
.intro-text { font-size: 9.5pt; line-height: 1.6; margin-bottom: 5mm; color: #1a1a1a; }
/* Angaben zur Erstattung */
.detail-table { width: 100%; border-collapse: collapse; font-size: 9pt; margin-bottom: 5mm; }
.detail-table td { padding: 2.2mm 3mm; border-bottom: 0.3pt solid #d8dde6; vertical-align: top; }
.detail-table tr:nth-child(even) td { background-color: #f7f8fb; }
.detail-table tr:last-child td { border-bottom: none; }
.detail-key { width: 42%; color: #555; }
.detail-val { font-weight: bold; }
/* Die Versicherung des Teilis unter den Angaben */
.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; }
/* 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()),
('participant_refund', 'header_recipient', '<div class="empfaenger">
<div class="empfaenger-bold">{recipient_name}</div>
<div>{recipient_address_1}</div>
{if:recipient_address_2}<div>{recipient_address_2}</div>{/if:recipient_address_2}
<div>{recipient_postcode} {recipient_city}</div>
</div>', 40, 1, NOW(), NOW()),
('participant_refund', 'emblem', '<img src="{asset:emblem}" class="emblem-img" alt="" />', 50, 1, NOW(), NOW()),
('participant_refund', 'logo', '<img src="{asset:logo}" class="logo-img" alt="" />', 60, 1, NOW(), NOW()),
('participant_refund', 'sender_data', '<div class="absender-block">
<div class="absender-name">{sender_name}</div>
{if:sender_address_2}<div>{sender_address_2}</div>{/if:sender_address_2}
{if:sender_address_3}<div>{sender_address_3}</div>{/if:sender_address_3}
{if:sender_address_1}<div style="margin-top:2.5mm;">{sender_address_1}</div>{/if:sender_address_1}
<div>{sender_postcode} {sender_city}</div>
{if:sender_email}<div style="margin-top:2.5mm;">{sender_email}</div>{/if:sender_email}
{if:sender_phone}<div>{sender_phone}</div>{/if:sender_phone}
{if:sender_tax_number}<div style="margin-top:2.5mm;">Steuernummer: {sender_tax_number}</div>{/if:sender_tax_number}
{if:sender_vat_id}<div>USt-IdNr.: {sender_vat_id}</div>{/if:sender_vat_id}
</div>', 70, 1, NOW(), NOW()),
('participant_refund', 'subject', '<div class="betreff">R&uuml;ckerstattung Nr.&nbsp;{document_number}</div>
<table class="ref-table">
<tr>
<td class="ref-key">Belegdatum:</td>
<td>{document_date}</td>
</tr>
<tr>
<td class="ref-key">Veranstaltung:</td>
<td>{event_name}</td>
</tr>
<tr>
<td class="ref-key">Zeitraum:</td>
<td>{service_period}</td>
</tr>
{if:unregistered_at}<tr>
<td class="ref-key">Abgemeldet am:</td>
<td>{unregistered_at}</td>
</tr>{/if:unregistered_at}
</table>', 80, 1, NOW(), NOW()),
('participant_refund', 'body', '<div class="intro-text">Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die R&uuml;ckerstattung wie folgt:</div>
{details_table}
<div class="declaration">{declaration_text}</div>', 90, 1, NOW(), NOW()),
('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
+6 -2
View File
@@ -22,10 +22,14 @@ RUN apt-get install -y nginx \
libxml2-dev \ libxml2-dev \
libmagickwand-dev \ libmagickwand-dev \
imagemagick \ imagemagick \
&& pecl install imagick \ && pecl install imagick pcov \
&& docker-php-ext-enable imagick && docker-php-ext-enable imagick pcov
#&& rm -rf /var/lib/apt/lists/* \ #&& rm -rf /var/lib/apt/lists/* \
# PCOV misst die Testabdeckung. Dauerhaft aktiv kostet es jede Anfrage Laufzeit, deshalb ist es hier
# ausgeschaltet und wird nur beim Testlauf zugeschaltet (composer test:coverage).
RUN echo "pcov.enabled=0" > /usr/local/etc/php/conf.d/zz-pcov.ini
RUN mkdir -p /run/nginx RUN mkdir -p /run/nginx
RUN docker-php-ext-install mysqli pdo pdo_mysql mbstring zip exif pcntl gd RUN docker-php-ext-install mysqli pdo pdo_mysql mbstring zip exif pcntl gd
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hallo {{$name}}!</h1>
<p>
vielen Dank &ndash; deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
uns vor.
</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Betrag:</td>
<td style="padding: 4px 0;"><strong>{{$amount}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
<td style="padding: 4px 0;">{{$reason}}</td>
</tr>
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
<td style="padding: 4px 0;">{{$accountOwner}}</td>
</tr>
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">IBAN:</td>
<td style="padding: 4px 0;">{{$accountIban}}</td>
</tr>
</table>
@if ($hasDocument)
<p>
Im Anhang findest du den Beleg über die Rückerstattung als PDF.
</p>
@endif
<p>
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde
dich bitte umgehend bei ihr.
</p>
<p>
@include('emails.subparts.disclaimer')
</p>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hallo {{$name}}!</h1>
<p>
du hast dich von der Veranstaltung "{{$eventTitle}}" abgemeldet. Die Aktionsleitung hat dir daraufhin
eine Rückerstattung deines Teilnahmebeitrags freigegeben.
</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Betrag:</td>
<td style="padding: 4px 0;"><strong>{{$amount}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
<td style="padding: 4px 0;">{{$reason}}</td>
</tr>
@if (!empty($reasonNote))
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Anmerkung:</td>
<td style="padding: 4px 0;">{{$reasonNote}}</td>
</tr>
@endif
</table>
<p>
Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den
folgenden Link ein:
</p>
<p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen</a>
</p>
<p style="font-size: 12px; color: #555;">
Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:<br />
{{$link}}
</p>
<p>
Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag
selbst steht fest und lässt sich über den Link nicht ändern &ndash; wenn du dazu Fragen hast, wende
dich bitte an die Aktionsleitung.
</p>
<p>
@include('emails.subparts.disclaimer')
</p>
</body>
</html>
+3
View File
@@ -26,6 +26,8 @@ require __DIR__ . '/../app/Domains/Invoice/Routes/api.php';
require __DIR__ . '/../app/Domains/Event/Routes/web.php'; require __DIR__ . '/../app/Domains/Event/Routes/web.php';
require __DIR__ . '/../app/Domains/Event/Routes/api.php'; require __DIR__ . '/../app/Domains/Event/Routes/api.php';
require __DIR__ . '/../app/Domains/ParticipantInvoice/Routes/api.php'; require __DIR__ . '/../app/Domains/ParticipantInvoice/Routes/api.php';
require __DIR__ . '/../app/Domains/ParticipantRefund/Routes/web.php';
require __DIR__ . '/../app/Domains/ParticipantRefund/Routes/api.php';
require __DIR__ . '/../app/Domains/Budget/Routes/web.php'; require __DIR__ . '/../app/Domains/Budget/Routes/web.php';
require __DIR__ . '/../app/Domains/Budget/Routes/api.php'; require __DIR__ . '/../app/Domains/Budget/Routes/api.php';
require __DIR__ . '/../app/Domains/Legal/Routes/web.php'; require __DIR__ . '/../app/Domains/Legal/Routes/web.php';
@@ -54,6 +56,7 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/retrieve-invoice-types', [GlobalDataProvider::class, 'getInvoiceTypes']); Route::get('/retrieve-invoice-types', [GlobalDataProvider::class, 'getInvoiceTypes']);
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']);
}); });
}); });
Binary file not shown.
Binary file not shown.
+106
View File
@@ -212,4 +212,110 @@ class DocumentTemplateAdminTest extends TestCase
$this->assertNull(DocumentAsset::where('name', 'altbestand')->first()); $this->assertNull(DocumentAsset::where('name', 'altbestand')->first());
} }
/*
|--------------------------------------------------------------------------
| Mehrere Dokumentarten
|--------------------------------------------------------------------------
*/
public function test_the_default_document_type_is_the_invoice(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
$this->getJson('/api/v1/admin/document-templates')
->assertOk()
->assertJsonPath('documentType', DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
->assertJsonPath('documentTypes.0.value', DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
->assertJsonPath('documentTypes.1.value', DocumentTemplate::TYPE_PARTICIPANT_REFUND);
} }
public function test_the_refund_type_returns_its_own_blocks_and_tokens(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'subject',
'content' => 'Rückerstattung {document_number}',
'sort_order' => 10,
]);
$response = $this->getJson('/api/v1/admin/document-templates?type=participant_refund');
$response->assertOk();
$response->assertJsonPath('documentType', DocumentTemplate::TYPE_PARTICIPANT_REFUND);
$response->assertJsonCount(1, 'blocks');
$response->assertJsonPath('blocks.0.block', 'subject');
// Die Platzhalter sind die des Belegs, nicht die der Rechnung.
$response->assertJsonPath('tokenGroups.refund.tokens.refund_amount.description', 'Erstattungsbetrag');
$response->assertJsonMissingPath('tokenGroups.body.tokens.positions_table');
}
public function test_saving_writes_only_into_the_selected_type(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'footer',
'content' => 'Beleg-Fuß',
'sort_order' => 20,
]);
$this->postJson('/api/v1/admin/document-templates', [
'type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'blocks' => ['footer' => 'neuer Beleg-Fuß'],
])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('neuer Beleg-Fuß', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_REFUND)
->where('block', 'footer')->first()->content);
// Der gleichnamige Block der Rechnung bleibt unberührt.
$this->assertSame('alt', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
->where('block', 'footer')->first()->content);
}
public function test_an_unknown_document_type_is_refused(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
$this->getJson('/api/v1/admin/document-templates?type=erfunden')->assertStatus(422);
$this->postJson('/api/v1/admin/document-templates', [
'type' => 'erfunden',
'blocks' => ['footer' => 'egal'],
])->assertStatus(422);
$this->post('/api/v1/admin/document-templates/preview', [
'type' => 'erfunden',
'blocks' => [],
])->assertStatus(422);
// Die Rechnungsvorlage darf davon nichts abbekommen haben.
$this->assertSame('alt', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
->where('block', 'footer')->first()->content);
}
public function test_preview_renders_the_refund_with_its_sample_data(): void
{
$this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN));
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{document_number}</div>',
'sort_order' => 10,
]);
$response = $this->post('/api/v1/admin/document-templates/preview', [
'type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'blocks' => [],
]);
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$this->assertStringStartsWith('%PDF', $response->getContent());
}
}
+797
View File
@@ -0,0 +1,797 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundCommand;
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\EfzStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
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\RelationModels\EventParticipationFee;
use App\Repositories\ParticipantRefundRepository;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Erstattungsvorgang: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili, Abbruch.
*/
class ParticipantRefundTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
/** Läuft mit, damit mehrere Teilis im selben Event nicht auf derselben Rechnungsnummer landen. */
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',
// Muss dem Host der Testanfragen entsprechen, sonst weist IdentifyTenant sie mit 404 ab.
'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']);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
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();
Mail::fake();
}
/** Minimale Vorlage: alles, was der Beleg braucht, in wenigen Blöcken. */
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:subject}{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_STYLE,
'content' => 'body { font-size: 10pt; }',
'sort_order' => 20,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'subject',
'content' => '<h1>Rückerstattung {document_number}</h1><p>{document_date} / {event_name}</p>',
'sort_order' => 30,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '<div>{recipient_name}</div>{details_table}',
'sort_order' => 40,
]);
}
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([
'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));
}
/** Standardfall dieser Tests: abgemeldet und vollständig bezahlt. */
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,
'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 = 300.0,
string $reason = RefundReason::SICKNESS,
?string $note = null,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
reasonNote: $note,
))->execute();
}
private function accept(
?ParticipantRefund $refund,
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
bool $declarationAccepted = true,
) {
return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: $owner,
accountIban: $iban,
declarationAccepted: $declarationAccepted,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Freigabe
|--------------------------------------------------------------------------
*/
public function test_release_creates_a_pending_refund_with_a_token(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$response = $this->release($participant);
$this->assertTrue($response->success);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $response->refund->status);
$this->assertSame(32, strlen($response->refund->token));
$this->assertEqualsWithDelta(300.0, $response->refund->amount->getAmount(), 0.001);
$this->assertNotNull($response->refund->released_at);
}
public function test_release_notifies_participant_and_contact_person(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
$this->release($participant);
Mail::assertSent(RefundReleasedMail::class, 2);
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com'));
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com'));
}
public function test_release_sends_only_one_mail_without_contact_person(): void
{
$this->release($this->makeParticipant($this->makeEvent()));
Mail::assertSent(RefundReleasedMail::class, 1);
}
public function test_release_is_rejected_for_a_registered_participant(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['unregistered_at' => null]);
$response = $this->release($participant);
$this->assertFalse($response->success);
$this->assertStringContainsString('abgemeldete', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_release_is_rejected_above_the_paid_amount(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 100.0]);
$response = $this->release($participant, 150.0);
$this->assertFalse($response->success);
$this->assertStringContainsString('nicht übersteigen', $response->message);
}
public function test_release_accepts_a_partial_amount(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$response = $this->release($participant, 220.0);
$this->assertTrue($response->success);
$this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001);
}
public function test_release_is_rejected_without_an_amount(): void
{
$response = $this->release($this->makeParticipant($this->makeEvent()), 0.0);
$this->assertFalse($response->success);
$this->assertStringContainsString('größer als 0', $response->message);
}
public function test_release_is_rejected_for_an_unknown_reason(): void
{
$response = $this->release($this->makeParticipant($this->makeEvent()), 300.0, 'erfunden');
$this->assertFalse($response->success);
$this->assertStringContainsString('Erstattungsgrund', $response->message);
}
public function test_free_text_reason_requires_a_note(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$response = $this->release($participant, 300.0, RefundReason::OTHER, ' ');
$this->assertFalse($response->success);
$this->assertStringContainsString('Erläuterung', $response->message);
}
public function test_note_is_only_stored_for_reasons_that_require_it(): void
{
$event = $this->makeEvent();
$withNote = $this->release($this->makeParticipant($event), 300.0, RefundReason::OTHER, 'Umzug');
$this->assertSame('Umzug', $withNote->refund->reason_note);
$ignored = $this->release($this->makeParticipant($event), 300.0, RefundReason::SICKNESS, 'wird verworfen');
$this->assertNull($ignored->refund->reason_note);
}
public function test_a_second_release_is_rejected_while_one_is_open(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->release($participant);
$response = $this->release($participant);
$this->assertFalse($response->success);
$this->assertStringContainsString('läuft bereits', $response->message);
$this->assertSame(1, ParticipantRefund::count());
}
public function test_a_new_release_is_possible_after_a_cancellation(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$first = $this->release($participant);
new CancelRefundCommand(new CancelRefundRequest($first->refund))->execute();
$this->assertTrue($this->release($participant)->success);
$this->assertSame(2, ParticipantRefund::count());
}
/*
|--------------------------------------------------------------------------
| Bestätigung durch den Teili
|--------------------------------------------------------------------------
*/
public function test_accept_stores_the_normalized_bank_details(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->accept($refund, 'Mika Muster', 'de02 1203 0000 0000 2020 51');
$this->assertTrue($response->success);
$refund->refresh();
$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_accept_sends_the_confirmation_with_the_document_attached(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
$refund = $this->release($participant)->refund;
$this->accept($refund);
Mail::assertSent(RefundAcceptedMail::class, 2);
$expected = sprintf(
'Rueckerstattung-WM-V-20260701-%s-R.pdf',
str_pad((string) $participant->invoice_sequence, 4, '0', STR_PAD_LEFT)
);
Mail::assertSent(RefundAcceptedMail::class, function (RefundAcceptedMail $mail) use ($expected): bool {
$attachments = $mail->attachments();
return count($attachments) === 1 && $attachments[0]->as === $expected;
});
}
public function test_accept_rejects_an_invalid_iban(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Gültige Struktur und Länge, aber falsche Prüfziffer -- ein klassischer Zahlendreher.
$response = $this->accept($refund, 'Mika Muster', 'DE02120300000000202015');
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountIban', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
/*
|--------------------------------------------------------------------------
| Die Erklärung
|--------------------------------------------------------------------------
*/
public function test_accept_is_rejected_without_the_declaration(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->accept($refund, declarationAccepted: false);
$this->assertFalse($response->success);
$this->assertArrayHasKey('declaration', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
$this->assertNull($refund->fresh()->account_iban);
}
public function test_the_declaration_cannot_be_skipped_over_http(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Ohne die Prüfung im Command ließe sich die Erklärung mit einem direkten Aufruf übergehen --
// und der Beleg schriebe dem Teili eine Zusicherung zu, die er nie abgegeben hat.
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
])
->assertOk()
->assertJsonPath('status', 'error')
->assertJsonStructure(['error_types' => ['declaration']]);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
public function test_the_public_page_serves_the_declaration_text_name(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'open'));
// Den Wortlaut holt die Seite über dieselbe Quelle, aus der ihn auch der Beleg nimmt.
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DECLARATION_TEXT)
->assertOk()
->assertSee('Ich versichere', false);
}
public function test_accept_requires_an_account_owner(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->accept($refund, ' ');
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountOwner', $response->errorTypes);
}
public function test_accept_on_an_unknown_token_reports_no_open_refund(): void
{
$response = $this->accept(null);
$this->assertFalse($response->success);
$this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message);
}
public function test_accept_on_a_cancelled_refund_reports_no_open_refund(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
$response = $this->accept($refund->fresh());
$this->assertFalse($response->success);
$this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message);
}
public function test_accept_twice_does_not_change_the_stored_details(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund, 'Mika Muster', 'DE02120300000000202051');
$response = $this->accept($refund->fresh(), 'Wer Anders', 'DE02500105170137075030');
$this->assertFalse($response->success);
$this->assertSame('Mika Muster', $refund->fresh()->account_owner);
}
/*
|--------------------------------------------------------------------------
| Abbruch
|--------------------------------------------------------------------------
*/
public function test_cancel_marks_the_refund_and_hides_it_from_the_participant(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
$response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
$this->assertTrue($response->success);
$this->assertSame(ParticipantRefund::STATUS_CANCELLED, $refund->fresh()->status);
$this->assertNotNull($refund->fresh()->cancelled_at);
// Für die Anmeldung sieht es danach aus wie vor der Freigabe.
$repository = new ParticipantRefundRepository();
$this->assertNull($repository->openFor($participant));
$this->assertNull($repository->currentFor($participant));
}
public function test_cancel_sends_no_mail(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
Mail::fake();
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
Mail::assertNothingSent();
}
public function test_cancel_is_rejected_after_acceptance(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund);
$response = new CancelRefundCommand(new CancelRefundRequest($refund->fresh()))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('bereits bestätigt', $response->message);
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
}
/*
|--------------------------------------------------------------------------
| Der gezahlte Beitrag bleibt unangetastet
|--------------------------------------------------------------------------
*/
public function test_amount_paid_is_untouched_through_the_whole_process(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->accept($refund);
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
|--------------------------------------------------------------------------
*/
private function makeAdmin(): User
{
return User::create([
'username' => 'al-' . uniqid() . '@example.com',
'email' => 'al-' . uniqid() . '@example.com',
'firstname' => 'Aktions',
'lastname' => 'Leitung',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_ADMIN,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
}
public function test_release_over_http_creates_the_refund(): void
{
$this->actingAs($this->makeAdmin());
$participant = $this->makeParticipant($this->makeEvent());
$response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,50',
'reason' => RefundReason::SICKNESS,
]);
$response->assertOk()->assertJsonPath('status', 'success');
$response->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
$refund = ParticipantRefund::first();
$this->assertEqualsWithDelta(220.5, $refund->amount->getAmount(), 0.001);
// Die Antwort trägt den Vorgang für die Liste -- aber nicht den Token-Link als Bankdaten.
$response->assertJsonPath('refund.amountValue', 220.5);
}
public function test_release_requires_a_login(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->post('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '300',
'reason' => RefundReason::SICKNESS,
])->assertRedirect('/login');
$this->assertSame(0, ParticipantRefund::count());
}
public function test_the_public_page_shows_the_open_refund(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->get('/rueckerstattung/' . $refund->token);
$response->assertOk();
$response->assertInertia(fn ($page) => $page
// shouldExist=false: die Pages liegen unter app/Domains/**/Views, nicht dort, wo der
// Finder von Inertia sucht.
->component('ParticipantRefund/Views/RefundPage', false)
->where('state', 'open')
->where('amount', '300,00 Euro')
->where('reason', 'Krankheitsbedingte Absage'));
}
public function test_the_public_page_never_exposes_bank_details(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund);
$response = $this->get('/rueckerstattung/' . $refund->fresh()->token);
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('state', 'accepted'));
$response->assertDontSee('DE02120300000000202051');
}
public function test_the_public_page_hides_a_cancelled_refund(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'unavailable'));
}
public function test_an_unknown_token_shows_the_same_page_as_a_cancelled_one(): void
{
$this->get('/rueckerstattung/' . str_repeat('x', 32))
->assertOk()
->assertInertia(fn ($page) => $page
->where('state', 'unavailable')
->where('message', AcceptRefundCommand::NO_OPEN_REFUND));
}
public function test_accept_over_http_works_without_a_login(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
}
public function test_field_errors_come_back_with_status_200(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Der HttpClient des Frontends verwirft Antworten mit Fehlerstatus -- Feldfehler müssen daher
// mit 200 zurückkommen, sonst sieht der Teili nie, was er falsch gemacht hat.
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02120300000000202015',
'declarationAccepted' => true,
])
->assertOk()
->assertJsonPath('status', 'error')
->assertJsonStructure(['error_types' => ['accountIban']]);
}
public function test_the_document_is_not_public(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund);
$this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document')
->assertRedirect('/login');
}
public function test_the_document_can_be_downloaded_by_the_event_management(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund);
$this->actingAs($this->makeAdmin());
$response = $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document');
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$this->assertStringStartsWith('%PDF', $response->getContent());
}
public function test_cancel_requires_a_login(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->post('/api/v1/participant-refund/' . $refund->token . '/cancel')
->assertRedirect('/login');
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
/*
|--------------------------------------------------------------------------
| Die Mails müssen sich auch wirklich rendern lassen
|--------------------------------------------------------------------------
*/
public function test_the_release_mail_renders_with_the_link(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund;
$html = new RefundReleasedMail($participant, $refund)->render();
$this->assertStringContainsString('220,00 Euro', $html);
$this->assertStringContainsString('Sonstiger Grund', $html);
$this->assertStringContainsString('Umzug', $html);
$this->assertStringContainsString('/rueckerstattung/' . $refund->token, $html);
// Der Baustein aus emails.subparts.disclaimer braucht eventTitle und eventEmail.
$this->assertStringContainsString('Sommerlager', $html);
$this->assertStringContainsString('e@example.com', $html);
}
public function test_the_release_mail_omits_the_note_row_when_there_is_none(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
$html = new RefundReleasedMail($participant, $refund)->render();
$this->assertStringNotContainsString('Anmerkung:', $html);
}
public function test_the_acceptance_mail_renders_with_the_bank_details(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh(), 'PDF-Inhalt', 'beleg.pdf')->render();
$this->assertStringContainsString('300,00 Euro', $html);
$this->assertStringContainsString('Mika Muster', $html);
// Im Anschreiben steht die IBAN in Vierergruppen.
$this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html);
$this->assertStringContainsString('Anhang', $html);
}
public function test_the_acceptance_mail_renders_without_a_document(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$refund = $this->release($participant)->refund;
$this->accept($refund);
$mail = new RefundAcceptedMail($participant, $refund->fresh());
$this->assertSame([], $mail->attachments());
$this->assertStringNotContainsString('Anhang', $mail->render());
}
/*
|--------------------------------------------------------------------------
| Gründe als Auswahl fürs Frontend
|--------------------------------------------------------------------------
*/
public function test_reason_options_are_ordered_and_flag_the_free_text(): void
{
$options = RefundReason::options();
$this->assertSame(
['sickness', 'event_cancelled', 'other'],
array_column($options, 'value')
);
$this->assertSame('Krankheitsbedingte Absage', $options[0]['label']);
$this->assertFalse($options[0]['requiresNote']);
$this->assertTrue($options[2]['requiresNote']);
}
public function test_reason_options_are_served_over_http(): void
{
$this->getJson('/api/v1/core/retrieve-refund-reasons')
->assertOk()
->assertJsonCount(3)
->assertJsonPath('0.value', RefundReason::SICKNESS)
->assertJsonPath('2.requiresNote', true);
}
}
+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\ParticipantRefundTokens;
use App\Enumerations\EfzStatus;
use App\Enumerations\RefundReason;
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\Providers\DocumentTemplateRenderProvider;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use ReflectionMethod;
use Tests\TestCase;
/**
* Der Erstattungsbeleg: Nummer, Inhalt und der Abgleich zwischen Platzhalter-Katalog und den tatsächlich
* gesetzten Werten.
*/
class RefundDocumentTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'invoice_sender_name' => 'Wilde Möhre e.V.',
'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' => 'Lommatzsch',
'postcode' => '01623',
'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']);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
$this->seedTemplate();
}
/** Die Vorlage setzt hier jeden Platzhalter ein, damit die Tests am gerenderten HTML prüfen können. */
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:header_recipient}{block:subject}{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'header_recipient',
'content' => '<p>{recipient_name} / {recipient_address_1} / {recipient_postcode} {recipient_city}</p>',
'sort_order' => 35,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_STYLE,
'content' => 'body { font-size: 10pt; }',
'sort_order' => 20,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'subject',
'content' => '<h1>Rückerstattung {document_number}</h1>'
. '<p>{document_date} / {event_name} / {service_period} / {unregistered_at}</p>'
. '<p>{sender_name} / {recipient_name}</p>',
'sort_order' => 30,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>'
. '<p>{account_owner} / {account_iban}</p>'
. '<p>{paid_amount} / {invoice_number}</p>'
. '<p>{declaration_text}</p>',
'sort_order' => 40,
]);
}
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([
'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, array $attributes = []): EventParticipant
{
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => 5,
'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));
}
/** Ein bestätigter Vorgang -- der Beleg entsteht erst dann. */
private function makeRefund(array $attributes = [], array $eventAttributes = []): ParticipantRefund
{
$event = $this->makeEvent($eventAttributes);
$participant = $this->makeParticipant($event);
return ParticipantRefund::create(array_merge([
'tenant' => $this->tenant->slug,
'event_id' => $event->id,
'event_participant_id' => $participant->id,
'token' => str_repeat('a', 32),
'status' => ParticipantRefund::STATUS_ACCEPTED,
'amount' => 300.0,
'reason' => RefundReason::SICKNESS,
'account_owner' => 'Mika Muster',
'account_iban' => 'DE02120300000000202051',
'released_at' => '2026-06-14 10:00:00',
'accepted_at' => '2026-06-18 09:30:00',
], $attributes));
}
private function document(ParticipantRefund $refund)
{
return new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
}
/** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */
private function html(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);
}
/*
|--------------------------------------------------------------------------
| Nummer und PDF
|--------------------------------------------------------------------------
*/
public function test_document_number_extends_the_invoice_number(): void
{
$response = $this->document($this->makeRefund());
$this->assertTrue($response->success);
$this->assertSame('WM-V-20260701-0005-R', $response->documentNumber);
$this->assertSame('Rueckerstattung-WM-V-20260701-0005-R.pdf', $response->filename);
}
public function test_a_pdf_is_produced(): void
{
$response = $this->document($this->makeRefund());
$this->assertStringStartsWith('%PDF', $response->pdfContent);
}
public function test_no_document_without_an_invoice_key(): void
{
$response = $this->document($this->makeRefund(eventAttributes: ['invoice_key' => null]));
$this->assertFalse($response->success);
$this->assertStringContainsString('Belegnummer', $response->message);
}
public function test_no_document_before_the_participant_confirmed(): void
{
$response = $this->document($this->makeRefund([
'status' => ParticipantRefund::STATUS_PENDING,
'account_owner' => null,
'account_iban' => null,
'accepted_at' => null,
]));
$this->assertFalse($response->success);
$this->assertStringContainsString('bestätigt', $response->message);
}
/*
|--------------------------------------------------------------------------
| Inhalt
|--------------------------------------------------------------------------
*/
public function test_document_shows_amount_reason_and_bank_details(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('300,00', $html);
$this->assertStringContainsString('Krankheitsbedingte Absage', $html);
$this->assertStringContainsString('Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', $html);
$this->assertStringContainsString('Mika Muster', $html);
// Im Beleg steht die IBAN in Vierergruppen.
$this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html);
}
public function test_document_shows_event_period_and_cancellation_date(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Sommerlager', $html);
$this->assertStringContainsString('16.07.2026 20.07.2026', $html);
$this->assertStringContainsString('12.06.2026', $html);
// Belegdatum ist der Tag der Bestätigung.
$this->assertStringContainsString('18.06.2026', $html);
}
public function test_the_reason_is_a_row_in_the_table_not_a_block_below_it(): void
{
$refund = $this->makeRefund();
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
$this->assertStringContainsString(
'<td class="detail-key">Begründung</td>'
. '<td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td>',
$details
);
// Der frühere Fließtext-Block unter der Tabelle ist verschwunden.
$this->assertStringNotContainsString('reason-note', $details);
$this->assertStringEndsWith('</table>', $details);
}
public function test_the_reason_row_is_dropped_when_there_is_no_text(): void
{
RefundReason::find(RefundReason::SICKNESS)->update(['document_text' => '']);
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
// Eine Beschriftung ohne Wert sieht auf einem Beleg nach Fehler aus.
$this->assertStringNotContainsString('Begründung', $details);
$this->assertStringContainsString('Krankheitsbedingte Absage', $details);
}
public function test_the_table_lists_the_rows_in_reading_order(): void
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
preg_match_all('/<td class="detail-key">([^<]+)<\/td>/', $details, $matches);
// Erst wer erklärt, dann worauf sich die Erstattung bezieht, dann warum, dann wohin das Geld geht.
$this->assertSame(
[
'Name',
'Gezahlter Teilnahmebeitrag',
'Rechnung',
'Erstattungsbetrag',
'Grund',
'Begründung',
'Kontoinhaber*in',
'IBAN',
],
$matches[1]
);
}
public function test_the_document_carries_no_contact_details(): void
{
// Für die Abrechnung genügen Name und Anschrift; E-Mail und Telefon gehören nicht auf einen
// Beleg, der durch die Buchhaltung und ins Archiv wandert.
$html = $this->html($this->makeRefund());
$this->assertStringNotContainsString('mika@example.com', $html);
$this->assertStringNotContainsString('0170 0000000', $html);
}
public function test_document_shows_the_paid_amount_next_to_the_refund(): void
{
// Teilerstattung: 220 von 300 gezahlten Euro.
$html = $this->html($this->makeRefund(['amount' => 220.0]));
$this->assertStringContainsString('300,00', $html);
$this->assertStringContainsString('220,00', $html);
// Die Nummer der Teilnahmerechnung, ohne das -R des Belegs.
$this->assertStringContainsString('WM-V-20260701-0005', $html);
}
public function test_document_carries_the_declaration(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html);
}
public function test_the_declaration_comes_from_the_page_text(): void
{
DB::table('page_texts')
->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)
->update(['content' => 'Eigener Wortlaut des Verbands.']);
$this->assertStringContainsString('Eigener Wortlaut des Verbands.', $this->html($this->makeRefund()));
}
public function test_the_declaration_falls_back_when_the_page_text_is_missing(): void
{
// Ohne Rückfallwert stünde hier ein Fatal Error auf null -- der Beleg muss trotzdem entstehen.
DB::table('page_texts')->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)->delete();
$this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund()));
}
public function test_free_text_reason_replaces_the_catalog_text(): void
{
$html = $this->html($this->makeRefund([
'reason' => RefundReason::OTHER,
'reason_note' => 'Umzug in ein anderes Bundesland',
]));
$this->assertStringContainsString('Sonstiger Grund', $html);
$this->assertStringContainsString('Umzug in ein anderes Bundesland', $html);
$this->assertStringNotContainsString('krankheitsbedingt', $html);
}
public function test_sender_is_read_live_from_the_tenant(): void
{
$refund = $this->makeRefund();
$this->tenant->update(['invoice_sender_name' => 'Neuer Name e.V.']);
$this->assertStringContainsString('Neuer Name e.V.', $this->html($refund));
}
public function test_single_day_event_shows_one_date(): void
{
$html = $this->html($this->makeRefund(eventAttributes: [
'start_date' => '2026-07-16',
'end_date' => '2026-07-16',
]));
$this->assertStringContainsString('16.07.2026', $html);
$this->assertStringNotContainsString('', $html);
}
/*
|--------------------------------------------------------------------------
| Katalog und Werte müssen dieselben Namen kennen
|--------------------------------------------------------------------------
*/
public function test_token_catalog_matches_the_generated_values(): void
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005-R');
$catalog = ParticipantRefundTokens::names();
sort($catalog);
$generated = array_keys($tokens);
sort($generated);
$this->assertSame($catalog, $generated);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit;
use App\Support\Iban;
use PHPUnit\Framework\TestCase;
class IbanTest extends TestCase
{
public function test_normalizes_spaces_and_case(): void
{
$this->assertSame('DE02120300000000202051', Iban::normalize('de02 1203 0000 0000 2020 51'));
$this->assertSame('DE02120300000000202051', Iban::normalize(" DE02\t1203000000002020 51 "));
}
public function test_formats_in_groups_of_four(): void
{
$this->assertSame('DE02 1203 0000 0000 2020 51', Iban::format('DE02120300000000202051'));
}
public function test_accepts_valid_ibans(): void
{
// Offizielle Testnummern der Deutschen Bundesbank bzw. der jeweiligen Zentralbanken.
foreach ([
'DE02120300000000202051',
'DE02500105170137075030',
'AT026000000001349870',
'CH0209000000100013997',
'FR1420041010050500013M02606',
'NL02ABNA0123456789',
] as $iban) {
$this->assertTrue(Iban::isValid($iban), $iban . ' sollte gültig sein');
}
}
public function test_rejects_a_wrong_check_digit(): void
{
// Zahlendreher in den letzten beiden Stellen -- Struktur und Länge stimmen weiterhin.
$this->assertFalse(Iban::isValid('DE02120300000000202015'));
}
public function test_rejects_wrong_length_for_the_country(): void
{
$this->assertFalse(Iban::isValid('DE0212030000000020205'));
$this->assertFalse(Iban::isValid('DE021203000000002020511'));
}
public function test_rejects_malformed_input(): void
{
foreach (['', ' ', 'kein IBAN', '1202120300000000202051', 'DEAB120300000000202051'] as $iban) {
$this->assertFalse(Iban::isValid($iban), $iban . ' sollte ungültig sein');
}
}
public function test_validates_the_normalized_form(): void
{
$this->assertTrue(Iban::isValid('de02 1203 0000 0000 2020 51'));
}
}