From c1c1a4f14373bf8ee4ac1fd8c688a71303a36ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnrher?= Date: Fri, 4 Sep 2026 01:15:37 +0200 Subject: [PATCH] Calculation errors for refunded amounts --- .../Event/Views/Partials/ParticipantData.vue | 12 + .../Event/Views/Partials/ParticipantsList.vue | 134 +++++- .../Views/Partials/ParticipationSummary.vue | 11 + .../AcceptRefund/AcceptRefundCommand.php | 23 +- .../CreateRefundDocumentCommand.php | 30 ++ .../ReleaseRefund/ReleaseRefundCommand.php | 60 ++- .../ReleaseRefund/ReleaseRefundRequest.php | 27 ++ .../Controllers/ReleaseRefundController.php | 3 + .../ParticipantRefundTokens.php | 4 + app/Enumerations/InvoiceType.php | 16 + app/Enumerations/RetentionReason.php | 67 +++ .../RefundAcceptedMail.php | 5 + app/Models/ParticipantRefund.php | 36 ++ app/Providers/GlobalDataProvider.php | 6 + app/Resources/CostUnitResource.php | 5 +- app/Resources/EventResource.php | 37 ++ app/Resources/ParticipantRefundResource.php | 6 + ...add_counts_as_expense_to_invoice_types.php | 38 ++ ..._09_08_140010_create_retention_reasons.php | 72 ++++ ...etention_reason_to_participant_refunds.php | 40 ++ .../emails/events/refund_accepted.blade.php | 7 + routes/web.php | 1 + tests/Feature/EventBudgetTest.php | 324 ++++++++++++++ tests/Feature/InvoiceTypeSelectionTest.php | 12 + tests/Feature/ParticipantRefundTest.php | 21 +- tests/Feature/RefundDirectCaptureTest.php | 29 +- tests/Feature/RefundInvoiceTest.php | 29 +- tests/Feature/RefundRetentionTest.php | 407 ++++++++++++++++++ 28 files changed, 1436 insertions(+), 26 deletions(-) create mode 100644 app/Enumerations/RetentionReason.php create mode 100644 database/migrations/2026_09_07_140010_add_counts_as_expense_to_invoice_types.php create mode 100644 database/migrations/2026_09_08_140010_create_retention_reasons.php create mode 100644 database/migrations/2026_09_08_140020_add_retention_reason_to_participant_refunds.php create mode 100644 tests/Feature/EventBudgetTest.php create mode 100644 tests/Feature/RefundRetentionTest.php diff --git a/app/Domains/Event/Views/Partials/ParticipantData.vue b/app/Domains/Event/Views/Partials/ParticipantData.vue index 88392b6..98eed73 100644 --- a/app/Domains/Event/Views/Partials/ParticipantData.vue +++ b/app/Domains/Event/Views/Partials/ParticipantData.vue @@ -388,6 +388,14 @@ function saveParticipant() { bestätigt am {{ props.participant.refund.acceptedAt }} + + + +
Einbehalten: {{ props.participant.refund.retainedAmount }} – + {{ props.participant.refund.retentionReasonLabel }} +
@@ -541,4 +549,8 @@ textarea { select { width: 262px; } + +.retention-note { + color: #8a6d00; +} diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue index 567e610..5783a92 100644 --- a/app/Domains/Event/Views/Partials/ParticipantsList.vue +++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue @@ -51,15 +51,82 @@ const openRefundDialogSwitch = ref(false); // Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über // den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits // vor -- dann wird die Erstattung sofort eingereicht. -const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''}); +const refundForm = reactive({ + amount: '', reason: '', reasonNote: '', + captureMode: 'participant', accountOwner: '', accountIban: '', + retentionReason: '', retentionReasonNote: '', +}); const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''}); const refundReasons = ref([]); +const retentionReasons = ref([]); const refundSaving = ref(false); const selectedRefundReason = computed( () => refundReasons.value.find(r => r.value === refundForm.reason) ?? null ); +const selectedRetentionReason = computed( + () => retentionReasons.value.find(r => r.value === refundForm.retentionReason) ?? null +); + +/** Was nach der Erstattung beim Verband bleibt -- die Grundlage für den Einbehaltungsblock. */ +const retainedAmount = computed(() => { + const paid = Number(showParticipant.value?.amountPaidValue ?? 0); + const refunded = Number((refundForm.amount ?? '').replace(',', '.')); + + if (!Number.isFinite(refunded)) { + return 0; + } + + const remaining = Math.round((paid - refunded) * 100) / 100; + + return remaining > 0.005 ? remaining : 0; +}); + +const hasRetention = computed(() => retainedAmount.value > 0); + +const retainedAmountReadable = computed( + () => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro' +); + +/** + * Ob abgesendet werden kann. Der Knopf erscheint erst dann -- was noch fehlt, soll die Aktionsleitung + * sehen, bevor sie klickt, statt danach eine Fehlermeldung zu lesen. + */ +const refundFormComplete = computed(() => { + const amount = Number((refundForm.amount ?? '').replace(',', '.')); + const paid = Number(showParticipant.value?.amountPaidValue ?? 0); + + if (!refundForm.amount || !(amount > 0) || amount > paid + 0.005) { + return false; + } + + if (!refundForm.reason) { + return false; + } + + if (selectedRefundReason.value?.requiresNote && !refundForm.reasonNote.trim()) { + return false; + } + + // Bleibt etwas beim Verband, muss begründet sein, warum. + if (hasRetention.value) { + if (!refundForm.retentionReason) { + return false; + } + + if (selectedRetentionReason.value?.requiresNote && !refundForm.retentionReasonNote.trim()) { + return false; + } + } + + if (refundForm.captureMode === 'management') { + return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== ''; + } + + return true; +}); + defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete']) function openParticipantDetails(input) { @@ -323,6 +390,8 @@ async function openRefundDialog(participant) { refundForm.captureMode = 'participant'; refundForm.accountOwner = ''; refundForm.accountIban = ''; + refundForm.retentionReason = ''; + refundForm.retentionReasonNote = ''; Object.keys(refundErrors).forEach(key => refundErrors[key] = ''); @@ -331,6 +400,11 @@ async function openRefundDialog(participant) { refundReasons.value = reasons ?? []; } + if (retentionReasons.value.length === 0) { + const reasons = await request('/api/v1/core/retrieve-retention-reasons', {method: 'GET'}); + retentionReasons.value = reasons ?? []; + } + openRefundDialogSwitch.value = true; } @@ -384,6 +458,9 @@ async function execRefund() { // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', + // Leer bei voller Erstattung -- dann gibt es nichts zu begründen. + retentionReason: hasRetention.value ? refundForm.retentionReason : '', + retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '', }, }); @@ -479,6 +556,15 @@ function mailToGroup(groupKey) { Gezahlt: /
Gesamt: + + + + Einbehalten: {{ participant.refund.retainedAmount }}
+ {{ participant.refund.retentionReasonLabel }} +
+

Zahlung buchen   @@ -655,6 +741,36 @@ function mailToGroup(groupKey) { + + + +