Dev 4.8.0 #15
@@ -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', []),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -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 }} – {{ 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;
|
||||||
|
}
|
||||||
+247
@@ -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() . ' €';
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
|
||||||
|
|
||||||
|
use App\Models\ParticipantRefund;
|
||||||
|
|
||||||
|
class CreateRefundDocumentRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ParticipantRefund $refund,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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 €</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 €</td></tr>'
|
||||||
|
. '<tr><td class="detail-key">Grund</td><td class="detail-val">Krankheitsbedingte Absage</td></tr>'
|
||||||
|
. '<tr><td class="detail-key">Begrü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>
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} · {sender_address_1}{/if:sender_address_1} · {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ückerstattung Nr. {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ü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
@@ -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 – 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 – wenn du dazu Fragen hast, wende
|
||||||
|
dich bitte an die Aktionsleitung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
@include('emails.subparts.disclaimer')
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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.
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user