Compare commits
2
Commits
b01288b5e0
...
5beb2b1d97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5beb2b1d97 | ||
|
|
efee20c16b |
@@ -10,6 +10,7 @@ import AmountInput from "../../../../Views/Components/AmountInput.vue";
|
|||||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||||
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
|
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
|
||||||
import ErrorText from "../../../../Views/Components/ErrorText.vue";
|
import ErrorText from "../../../../Views/Components/ErrorText.vue";
|
||||||
|
import IbanInput from "../../../../Views/Components/IbanInput.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@@ -47,10 +48,11 @@ const openCancelDialog = ref(false);
|
|||||||
const openPartialPaymentDialogSwitch = ref(false);
|
const openPartialPaymentDialogSwitch = ref(false);
|
||||||
const openRefundDialogSwitch = ref(false);
|
const openRefundDialogSwitch = ref(false);
|
||||||
|
|
||||||
// Der Erstattungsdialog. Betrag und Grund werden hier gesetzt; die Bankverbindung erfasst der Teili
|
// Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über
|
||||||
// selbst über den Link, den die Freigabe ihm schickt.
|
// den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits
|
||||||
const refundForm = reactive({amount: '', reason: '', reasonNote: ''});
|
// vor -- dann wird die Erstattung sofort eingereicht.
|
||||||
const refundErrors = reactive({amount: '', reason: '', reasonNote: ''});
|
const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''});
|
||||||
|
const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''});
|
||||||
const refundReasons = ref([]);
|
const refundReasons = ref([]);
|
||||||
const refundSaving = ref(false);
|
const refundSaving = ref(false);
|
||||||
|
|
||||||
@@ -317,9 +319,12 @@ async function openRefundDialog(participant) {
|
|||||||
refundForm.amount = participant.amountPaid?.short ?? '';
|
refundForm.amount = participant.amountPaid?.short ?? '';
|
||||||
refundForm.reason = '';
|
refundForm.reason = '';
|
||||||
refundForm.reasonNote = '';
|
refundForm.reasonNote = '';
|
||||||
refundErrors.amount = '';
|
// Vorgabe ist der übliche Weg über den Teili.
|
||||||
refundErrors.reason = '';
|
refundForm.captureMode = 'participant';
|
||||||
refundErrors.reasonNote = '';
|
refundForm.accountOwner = '';
|
||||||
|
refundForm.accountIban = '';
|
||||||
|
|
||||||
|
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
|
||||||
|
|
||||||
if (refundReasons.value.length === 0) {
|
if (refundReasons.value.length === 0) {
|
||||||
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
|
const reasons = await request('/api/v1/core/retrieve-refund-reasons', {method: 'GET'});
|
||||||
@@ -333,9 +338,7 @@ function validateRefund() {
|
|||||||
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
|
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
|
||||||
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
|
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
|
||||||
|
|
||||||
refundErrors.amount = '';
|
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
|
||||||
refundErrors.reason = '';
|
|
||||||
refundErrors.reasonNote = '';
|
|
||||||
|
|
||||||
if (!refundForm.amount || !(amount > 0)) {
|
if (!refundForm.amount || !(amount > 0)) {
|
||||||
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
|
refundErrors.amount = 'Bitte gib einen Betrag größer als 0 ein.';
|
||||||
@@ -349,7 +352,19 @@ function validateRefund() {
|
|||||||
refundErrors.reasonNote = 'Bitte erläutere den Grund.';
|
refundErrors.reasonNote = 'Bitte erläutere den Grund.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return !refundErrors.amount && !refundErrors.reason && !refundErrors.reasonNote;
|
// Beim Direktweg wird sofort eingereicht -- danach gibt es keine Gelegenheit mehr zu berichtigen.
|
||||||
|
// Ob die IBAN wirklich stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
|
||||||
|
if (refundForm.captureMode === 'management') {
|
||||||
|
if (!refundForm.accountOwner.trim()) {
|
||||||
|
refundErrors.accountOwner = 'Bitte gib an, wem das Konto gehört.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refundForm.accountIban.trim()) {
|
||||||
|
refundErrors.accountIban = 'Bitte gib die IBAN des Kontos ein.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(refundErrors).every(message => !message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execRefund() {
|
async function execRefund() {
|
||||||
@@ -366,13 +381,22 @@ async function execRefund() {
|
|||||||
amount: refundForm.amount,
|
amount: refundForm.amount,
|
||||||
reason: refundForm.reason,
|
reason: refundForm.reason,
|
||||||
reasonNote: refundForm.reasonNote,
|
reasonNote: refundForm.reasonNote,
|
||||||
|
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
|
||||||
|
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
|
||||||
|
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data?.status === 'success') {
|
if (data?.status === 'success') {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
// Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
|
// Reaktiv statt per getElementById: die Meta-Zeile hängt am Vorgang und wechselt mit ihm.
|
||||||
|
// Beim Direktweg steht dort sofort "Erstattet" samt Abrechnungsnummer.
|
||||||
showParticipant.value.refund = data.refund;
|
showParticipant.value.refund = data.refund;
|
||||||
|
// Der gezahlte Beitrag wird beim Einreichen auf 0 gesetzt -- sonst zeigte die Zeile weiter
|
||||||
|
// den alten Stand, bis jemand neu lädt.
|
||||||
|
if (data.refund?.status === 'accepted') {
|
||||||
|
showParticipant.value.amountPaidValue = 0;
|
||||||
|
}
|
||||||
openRefundDialogSwitch.value = false;
|
openRefundDialogSwitch.value = false;
|
||||||
} else {
|
} else {
|
||||||
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
|
toast.error(data?.message ?? 'Die Erstattung konnte nicht freigegeben werden.');
|
||||||
@@ -524,6 +548,9 @@ function mailToGroup(groupKey) {
|
|||||||
|
|
||||||
<template v-else-if="participant.refund?.status === 'accepted'">
|
<template v-else-if="participant.refund?.status === 'accepted'">
|
||||||
| <strong>Erstattet:</strong> {{ participant.refund.amount }}
|
| <strong>Erstattet:</strong> {{ participant.refund.amount }}
|
||||||
|
<template v-if="participant.refund.invoiceNumber">
|
||||||
|
· Abrechnung {{ participant.refund.invoiceNumber }}
|
||||||
|
</template>
|
||||||
<span class="link" @click="downloadRefundDocument(participant)">Beleg</span>
|
<span class="link" @click="downloadRefundDocument(participant)">Beleg</span>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -600,8 +627,7 @@ function mailToGroup(groupKey) {
|
|||||||
>
|
>
|
||||||
<p class="refund-intro">
|
<p class="refund-intro">
|
||||||
{{ showParticipant?.fullname }} hat
|
{{ showParticipant?.fullname }} hat
|
||||||
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt. Nach der Freigabe erhält
|
<strong>{{ showParticipant?.amountPaid?.readable }}</strong> gezahlt.
|
||||||
der Teili eine E-Mail und trägt seine Bankverbindung selbst ein.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="refund-field">
|
<div class="refund-field">
|
||||||
@@ -629,8 +655,49 @@ function mailToGroup(groupKey) {
|
|||||||
<ErrorText :message="refundErrors.reasonNote" />
|
<ErrorText :message="refundErrors.reasonNote" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird
|
||||||
|
sofort eingereicht. Er bekommt den Beleg trotzdem.
|
||||||
|
-->
|
||||||
|
<div class="refund-field">
|
||||||
|
<label class="refund-choice">
|
||||||
|
<input type="radio" value="participant" v-model="refundForm.captureMode" />
|
||||||
|
Teilnehmer*in trägt die Bankverbindung selbst ein
|
||||||
|
</label>
|
||||||
|
<label class="refund-choice">
|
||||||
|
<input type="radio" value="management" v-model="refundForm.captureMode" />
|
||||||
|
Bankverbindung liegt mir vor
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="refundForm.captureMode === 'management'">
|
||||||
|
<div class="refund-field">
|
||||||
|
<label for="refund_account_owner">Kontoinhaber*in</label>
|
||||||
|
<input
|
||||||
|
id="refund_account_owner"
|
||||||
|
v-model="refundForm.accountOwner"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
<ErrorText :message="refundErrors.accountOwner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="refund-field">
|
||||||
|
<label for="refund_account_iban">IBAN</label>
|
||||||
|
<IbanInput id="refund_account_iban" v-model="refundForm.accountIban" class="form-input" />
|
||||||
|
<ErrorText :message="refundErrors.accountIban" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="refund-hint">
|
||||||
|
Die Erstattung wird sofort als Abrechnung eingereicht. Der Teili erhält den Beleg per
|
||||||
|
E-Mail und kann die Angaben prüfen.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<button class="button" :disabled="refundSaving" @click="execRefund()">
|
<button class="button" :disabled="refundSaving" @click="execRefund()">
|
||||||
{{ refundSaving ? 'Wird freigegeben…' : 'Erstattung freigeben' }}
|
<template v-if="refundSaving">Wird gespeichert…</template>
|
||||||
|
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
|
||||||
|
<template v-else>Erstattung freigeben</template>
|
||||||
</button>
|
</button>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -665,10 +732,32 @@ function mailToGroup(groupKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refund-field select,
|
.refund-field select,
|
||||||
.refund-field textarea {
|
.refund-field textarea,
|
||||||
|
.refund-field .form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refund-choice {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refund-choice input {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refund-hint {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-left: 3px solid #f5c400;
|
||||||
|
background-color: #fffef5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
.participants-table {
|
.participants-table {
|
||||||
width: 95%;
|
width: 95%;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
|
|||||||
@@ -2,12 +2,27 @@
|
|||||||
|
|
||||||
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
|
namespace App\Domains\ParticipantRefund\Actions\AcceptRefund;
|
||||||
|
|
||||||
|
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
|
||||||
|
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
|
||||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
||||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentResponse;
|
||||||
|
use App\Enumerations\InvoiceType;
|
||||||
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
||||||
|
use App\Models\CostUnit;
|
||||||
|
use App\Models\Invoice;
|
||||||
use App\Models\ParticipantRefund;
|
use App\Models\ParticipantRefund;
|
||||||
|
use App\Providers\FileWriteProvider;
|
||||||
|
use App\Providers\UploadFileProvider;
|
||||||
|
use App\Repositories\CostUnitRepository;
|
||||||
use App\Support\Iban;
|
use App\Support\Iban;
|
||||||
|
use App\ValueObjects\Amount;
|
||||||
|
use App\ValueObjects\InvoiceFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung.
|
* Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung.
|
||||||
@@ -52,7 +67,10 @@ class AcceptRefundCommand
|
|||||||
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
|
// Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg
|
||||||
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
|
// als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf
|
||||||
// dem PDF eine Zusicherung, die niemand abgegeben hat.
|
// dem PDF eine Zusicherung, die niemand abgegeben hat.
|
||||||
if (!$this->request->declarationAccepted) {
|
//
|
||||||
|
// Nimmt die Aktionsleitung die Angaben auf, kreuzt naturgemäß niemand etwas an. Nachvollziehbar
|
||||||
|
// bleibt es trotzdem: `captured_by` hält fest, wer sie aufgenommen hat, und der Beleg weist es aus.
|
||||||
|
if (!$this->request->declarationAccepted && $this->request->capturedBy === null) {
|
||||||
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
|
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,13 +90,46 @@ class AcceptRefundCommand
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den
|
||||||
|
// Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen.
|
||||||
|
$costUnit = $this->costUnit($refund);
|
||||||
|
if ($costUnit === null) {
|
||||||
|
$response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. '
|
||||||
|
. 'Bitte wende dich an die Aktionsleitung.';
|
||||||
|
|
||||||
|
Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [
|
||||||
|
'refund_id' => $refund->id,
|
||||||
|
'event_id' => $refund->event_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
|
||||||
|
// Einreichen, soll auch kein Beleg gelten.
|
||||||
|
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
|
||||||
$refund->account_owner = $owner;
|
$refund->account_owner = $owner;
|
||||||
$refund->account_iban = $iban;
|
$refund->account_iban = $iban;
|
||||||
|
$refund->captured_by = $this->request->capturedBy;
|
||||||
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
|
$refund->status = ParticipantRefund::STATUS_ACCEPTED;
|
||||||
$refund->accepted_at = now();
|
$refund->accepted_at = now();
|
||||||
$refund->save();
|
$refund->save();
|
||||||
|
|
||||||
$this->notify($refund);
|
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
|
||||||
|
|
||||||
|
$invoice = $this->createInvoice($refund, $costUnit, $document);
|
||||||
|
|
||||||
|
// Erst jetzt, nicht früher: Beleg und Anmerkung der Abrechnung weisen den gezahlten Beitrag
|
||||||
|
// aus und läsen sonst bereits die 0.
|
||||||
|
$this->clearAmountPaid($refund);
|
||||||
|
|
||||||
|
$refund->invoice_id = $invoice->id;
|
||||||
|
$refund->save();
|
||||||
|
|
||||||
|
return $document;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->notify($refund, $document);
|
||||||
|
|
||||||
$response->success = true;
|
$response->success = true;
|
||||||
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
|
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.';
|
||||||
@@ -87,30 +138,162 @@ class AcceptRefundCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
|
* Die Kostenstelle der Veranstaltung.
|
||||||
*
|
*
|
||||||
* Scheitert die Belegerzeugung, geht die Mail trotzdem raus -- der Vorgang ist gespeichert, und die
|
* Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token.
|
||||||
* Aktionsleitung kann den Beleg jederzeit erneut abrufen. Ein Fehler hier darf nicht dazu führen,
|
* Der Repository-Check greift sonst auf `auth()->user()->id` zu und liefe in einen Fehler.
|
||||||
* dass der Teili gar nichts hört.
|
*
|
||||||
|
* Bewusst ohne Prüfung auf `allow_new`/`archived`: Eine Erstattung fällt oft erst nach dem Ende der
|
||||||
|
* Veranstaltung an, wenn die Kostenstelle längst geschlossen ist. Sie gehört trotzdem dorthin -- und
|
||||||
|
* der reguläre Weg über SaveInvoiceController prüft das ebenso wenig.
|
||||||
*/
|
*/
|
||||||
private function notify(ParticipantRefund $refund): void
|
private function costUnit(ParticipantRefund $refund): ?CostUnit
|
||||||
{
|
{
|
||||||
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute();
|
if ($refund->event->cost_unit_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CostUnitRepository()->getById($refund->event->cost_unit_id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reicht die Erstattung als gewöhnliche Auslagenabrechnung ein.
|
||||||
|
*
|
||||||
|
* Über denselben Command wie jede von Hand erfasste Abrechnung: damit stimmen Nummernkreis, Status
|
||||||
|
* `new`, die Bestätigungsmail an den Teili und die Benachrichtigung der Kassenwart*innen mit dem
|
||||||
|
* überein, was die Buchhaltung kennt.
|
||||||
|
*/
|
||||||
|
private function createInvoice(
|
||||||
|
ParticipantRefund $refund,
|
||||||
|
CostUnit $costUnit,
|
||||||
|
CreateRefundDocumentResponse $document,
|
||||||
|
): Invoice {
|
||||||
|
$participant = $refund->participant;
|
||||||
|
|
||||||
|
$invoiceRequest = new CreateInvoiceRequest(
|
||||||
|
costUnit: $costUnit,
|
||||||
|
// getOfficialName() und nicht getFullName(): letzteres enthält HTML für die Oberfläche.
|
||||||
|
contactName: $participant->getOfficialName(),
|
||||||
|
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
|
||||||
|
totalAmount: $refund->amount?->getAmount() ?? 0.0,
|
||||||
|
receiptFile: $this->storeReceipt($costUnit, $document),
|
||||||
|
isDonation: false,
|
||||||
|
userId: $participant->user_id,
|
||||||
|
contactEmail: $participant->email_1,
|
||||||
|
contactPhone: $participant->phone_1,
|
||||||
|
// Die Bankverbindung stammt aus dem Vorgang, nicht vom Teilnehmer: das Konto kann einem
|
||||||
|
// Elternteil gehören.
|
||||||
|
accountOwner: $refund->account_owner,
|
||||||
|
accountIban: $refund->account_iban,
|
||||||
|
|
||||||
|
// Die folgenden vier gehören zu Reisekosten und Freitext-Typen und sind hier leer. Sie
|
||||||
|
// müssen trotzdem stehen: `transportations` hat als einziger Parameter keinen Vorgabewert,
|
||||||
|
// und PHP macht damit auch alle optionalen Parameter davor zu Pflichtangaben.
|
||||||
|
invoiceTypeExtended: null,
|
||||||
|
travelRoute: null,
|
||||||
|
distance: null,
|
||||||
|
passengers: null,
|
||||||
|
transportations: null,
|
||||||
|
|
||||||
|
// MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas
|
||||||
|
// steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen".
|
||||||
|
paymentPurpose: null,
|
||||||
|
notices: $this->notice($refund),
|
||||||
|
);
|
||||||
|
|
||||||
|
$invoiceResponse = new CreateInvoiceCommand($invoiceRequest)->execute();
|
||||||
|
|
||||||
|
if (!$invoiceResponse->success || $invoiceResponse->invoice === null) {
|
||||||
|
// Rollt die Transaktion zurück -- der Vorgang bleibt offen, der Teili kann es erneut versuchen.
|
||||||
|
throw new RuntimeException('Die Abrechnung zur Beitragserstattung konnte nicht angelegt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $invoiceResponse->invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt den Eigenbeleg dort ab, wo auch hochgeladene Belege liegen, und verpackt ihn für die
|
||||||
|
* Abrechnung. `CreateInvoiceCommand` speichert nur den Pfad und schreibt selbst keine Dateien.
|
||||||
|
*/
|
||||||
|
private function storeReceipt(CostUnit $costUnit, CreateRefundDocumentResponse $document): ?InvoiceFile
|
||||||
|
{
|
||||||
|
if (!$document->success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = UploadFileProvider::directoryFor($costUnit) . '/' . $document->filename;
|
||||||
|
|
||||||
|
new FileWriteProvider($path, $document->pdfContent)->writeToFile();
|
||||||
|
|
||||||
|
$receipt = new InvoiceFile();
|
||||||
|
// Beide Eigenschaften sind typisiert und ohne Vorbelegung; gespeichert wird nur `fullPath`.
|
||||||
|
$receipt->filename = $document->filename;
|
||||||
|
$receipt->fullPath = $path;
|
||||||
|
|
||||||
|
return $receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Anmerkung auf der Abrechnung.
|
||||||
|
*
|
||||||
|
* Sie nennt den gezahlten Beitrag, weil er am Teilnehmer gleich auf 0 gesetzt wird
|
||||||
|
* ({@see self::clearAmountPaid()}) -- die Schatzmeisterei kann den Vorgang so nachvollziehen, ohne
|
||||||
|
* den vorherigen Stand irgendwo suchen zu müssen.
|
||||||
|
*
|
||||||
|
* Gekürzt wird nur der vordere, freie Teil: Veranstaltungsname und Grund sind beliebig lang, der
|
||||||
|
* Betrag darf nie abgeschnitten werden.
|
||||||
|
*/
|
||||||
|
private function notice(ParticipantRefund $refund): string
|
||||||
|
{
|
||||||
|
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro';
|
||||||
|
|
||||||
|
return Str::limit(sprintf(
|
||||||
|
'Rückerstattung Teilnahmebeitrag %s – %s',
|
||||||
|
$refund->event->name,
|
||||||
|
$refund->reasonLabel()
|
||||||
|
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den gezahlten Beitrag des Teilis auf 0.
|
||||||
|
*
|
||||||
|
* Mit der eingereichten Abrechnung ist der Beitrag nicht mehr beim Verband, sondern auf dem Weg
|
||||||
|
* zurück -- die Zahlungsübersichten der Aktionsleitung sollen ihn nicht länger als offen führen. Der
|
||||||
|
* ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem Beleg.
|
||||||
|
*/
|
||||||
|
private function clearAmountPaid(ParticipantRefund $refund): void
|
||||||
|
{
|
||||||
|
$participant = $refund->participant;
|
||||||
|
$participant->amount_paid = new Amount(0.0, 'Euro');
|
||||||
|
$participant->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die eigene Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson.
|
||||||
|
*
|
||||||
|
* Sie kommt zusätzlich zu der, die CreateInvoiceCommand verschickt: diese trägt den Beleg, jene ist
|
||||||
|
* die Quittung des Abrechnungssystems. Erst nach der Transaktion, damit nichts verschickt wird, was
|
||||||
|
* anschließend zurückgerollt würde.
|
||||||
|
*
|
||||||
|
* Scheitert die Belegerzeugung, geht die Mail ohne Anhang raus statt gar nicht.
|
||||||
|
*/
|
||||||
|
private function notify(ParticipantRefund $refund, CreateRefundDocumentResponse $document): void
|
||||||
|
{
|
||||||
$pdf = $document->success ? $document->pdfContent : null;
|
$pdf = $document->success ? $document->pdfContent : null;
|
||||||
$filename = $document->success ? $document->filename : null;
|
$filename = $document->success ? $document->filename : null;
|
||||||
|
|
||||||
$participant = $refund->participant;
|
$participant = $refund->participant;
|
||||||
|
|
||||||
Mail::to($participant->email_1)->send(new RefundAcceptedMail(
|
$recipients = [$participant->email_1];
|
||||||
participant: $participant,
|
|
||||||
refund: $refund,
|
|
||||||
pdfContent: $pdf,
|
|
||||||
pdfFilename: $filename,
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($participant->email_2 !== null) {
|
// `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
|
||||||
Mail::to($participant->email_2)->send(new RefundAcceptedMail(
|
// Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere.
|
||||||
|
if (filled($participant->email_2)) {
|
||||||
|
$recipients[] = $participant->email_2;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
Mail::to($recipient)->send(new RefundAcceptedMail(
|
||||||
participant: $participant,
|
participant: $participant,
|
||||||
refund: $refund,
|
refund: $refund,
|
||||||
pdfContent: $pdf,
|
pdfContent: $pdf,
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class AcceptRefundRequest
|
|||||||
public readonly string $accountIban,
|
public readonly string $accountIban,
|
||||||
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
|
/** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
|
||||||
public readonly bool $declarationAccepted = false,
|
public readonly bool $declarationAccepted = false,
|
||||||
|
/**
|
||||||
|
* Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag.
|
||||||
|
*
|
||||||
|
* Dann kreuzt niemand die Erklärung an -- sie wird stellvertretend aufgenommen, und der Beleg
|
||||||
|
* weist genau das aus.
|
||||||
|
*/
|
||||||
|
public readonly ?int $capturedBy = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -104,6 +104,28 @@ class CreateRefundDocumentCommand
|
|||||||
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
|
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
|
||||||
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
|
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat.
|
||||||
|
*
|
||||||
|
* Er nennt Name und Datum, weil in diesem Fall niemand die Erklärung darüber angekreuzt hat: Wer den
|
||||||
|
* Beleg prüft, soll erkennen, dass dort eine aufgenommene Angabe steht und keine Bestätigung des
|
||||||
|
* Teilis selbst. Beim gewöhnlichen Weg bleibt der Platzhalter leer und der Block fällt weg.
|
||||||
|
*/
|
||||||
|
private function captureNote(): string
|
||||||
|
{
|
||||||
|
if (!$this->refund->wasCapturedByManagement()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->refund->capturedBy()->first()?->getOfficialName();
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Angaben aufgenommen durch %s am %s.',
|
||||||
|
trim((string) $name) !== '' ? $name : 'die Aktionsleitung',
|
||||||
|
$this->refund->accepted_at?->format('d.m.Y') ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function declarationText(): string
|
private function declarationText(): string
|
||||||
{
|
{
|
||||||
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
|
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content;
|
||||||
@@ -178,6 +200,7 @@ class CreateRefundDocumentCommand
|
|||||||
'account_iban' => $this->formatIban((string) $refund->account_iban),
|
'account_iban' => $this->formatIban((string) $refund->account_iban),
|
||||||
|
|
||||||
'declaration_text' => $this->declarationText(),
|
'declaration_text' => $this->declarationText(),
|
||||||
|
'capture_note' => $this->captureNote(),
|
||||||
|
|
||||||
'details_table' => $this->renderDetails(),
|
'details_table' => $this->renderDetails(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
|
namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
|
||||||
|
|
||||||
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
||||||
use App\Enumerations\RefundReason;
|
use App\Enumerations\RefundReason;
|
||||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||||
use App\Models\EventParticipant;
|
use App\Models\EventParticipant;
|
||||||
use App\Models\ParticipantRefund;
|
use App\Models\ParticipantRefund;
|
||||||
use App\Repositories\ParticipantRefundRepository;
|
use App\Repositories\ParticipantRefundRepository;
|
||||||
|
use App\Support\Iban;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gibt die Erstattung eines Teilnahmebeitrags frei.
|
* Gibt die Erstattung eines Teilnahmebeitrags frei.
|
||||||
@@ -40,6 +45,9 @@ class ReleaseRefundCommand
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die,
|
||||||
|
// soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion.
|
||||||
|
$refund = DB::transaction(function (): ParticipantRefund {
|
||||||
$refund = ParticipantRefund::create([
|
$refund = ParticipantRefund::create([
|
||||||
'tenant' => $this->participant->tenant,
|
'tenant' => $this->participant->tenant,
|
||||||
'event_id' => $this->participant->event_id,
|
'event_id' => $this->participant->event_id,
|
||||||
@@ -53,15 +61,50 @@ class ReleaseRefundCommand
|
|||||||
'released_at' => now(),
|
'released_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->request->hasBankDetails()) {
|
||||||
|
$this->submitDirectly($refund);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $refund;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$this->request->hasBankDetails()) {
|
||||||
$this->notify($refund);
|
$this->notify($refund);
|
||||||
|
}
|
||||||
|
|
||||||
$response->success = true;
|
$response->success = true;
|
||||||
$response->refund = $refund;
|
$response->refund = $refund->fresh();
|
||||||
$response->message = 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
|
$response->message = $this->request->hasBankDetails()
|
||||||
|
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
|
||||||
|
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reicht die Erstattung sofort ein, ohne den Umweg über den Teili.
|
||||||
|
*
|
||||||
|
* Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen
|
||||||
|
* des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab.
|
||||||
|
*/
|
||||||
|
private function submitDirectly(ParticipantRefund $refund): void
|
||||||
|
{
|
||||||
|
$acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest(
|
||||||
|
refund: $refund,
|
||||||
|
accountOwner: (string) $this->request->accountOwner,
|
||||||
|
accountIban: (string) $this->request->accountIban,
|
||||||
|
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
|
||||||
|
// fest, und der Beleg weist es aus.
|
||||||
|
capturedBy: auth()->id(),
|
||||||
|
))->execute();
|
||||||
|
|
||||||
|
if (!$acceptResponse->success) {
|
||||||
|
// Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen
|
||||||
|
// Vorgang vorfinden, der nirgends eingereicht ist.
|
||||||
|
throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
|
* Alle Gründe, aus denen eine Freigabe nicht zulässig ist.
|
||||||
*
|
*
|
||||||
@@ -98,6 +141,34 @@ class ReleaseRefundCommand
|
|||||||
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
|
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->rejectBankDetails();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt
|
||||||
|
* also keine zweite Gelegenheit, Angaben zu berichtigen.
|
||||||
|
*/
|
||||||
|
private function rejectBankDetails(): ?string
|
||||||
|
{
|
||||||
|
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
|
||||||
|
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
|
||||||
|
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->request->hasBankDetails()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Iban::isValid((string) $this->request->accountIban)) {
|
||||||
|
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
|
||||||
|
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
|
||||||
|
if ($this->participant->event->cost_unit_id === null) {
|
||||||
|
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +190,16 @@ class ReleaseRefundCommand
|
|||||||
*/
|
*/
|
||||||
private function notify(ParticipantRefund $refund): void
|
private function notify(ParticipantRefund $refund): void
|
||||||
{
|
{
|
||||||
Mail::to($this->participant->email_1)->send(new RefundReleasedMail(
|
$recipients = [$this->participant->email_1];
|
||||||
participant: $this->participant,
|
|
||||||
refund: $refund,
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($this->participant->email_2 !== null) {
|
// `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
|
||||||
Mail::to($this->participant->email_2)->send(new RefundReleasedMail(
|
// Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere.
|
||||||
|
if (filled($this->participant->email_2)) {
|
||||||
|
$recipients[] = $this->participant->email_2;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
Mail::to($recipient)->send(new RefundReleasedMail(
|
||||||
participant: $this->participant,
|
participant: $this->participant,
|
||||||
refund: $refund,
|
refund: $refund,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ class ReleaseRefundRequest
|
|||||||
public readonly Amount $amount,
|
public readonly Amount $amount,
|
||||||
public readonly string $reason,
|
public readonly string $reason,
|
||||||
public readonly ?string $reasonNote = null,
|
public readonly ?string $reasonNote = null,
|
||||||
|
/**
|
||||||
|
* Die Bankverbindung, wenn sie der Aktionsleitung bereits vorliegt.
|
||||||
|
*
|
||||||
|
* Sind beide gesetzt, entfällt der Umweg über den Teili: die Erstattung wird sofort eingereicht.
|
||||||
|
* Bleiben sie leer, läuft der übliche Weg über den Bestätigungslink.
|
||||||
|
*/
|
||||||
|
public readonly ?string $accountOwner = null,
|
||||||
|
public readonly ?string $accountIban = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ob die Erstattung ohne Zutun des Teilis eingereicht werden kann. */
|
||||||
|
public function hasBankDetails(): bool
|
||||||
|
{
|
||||||
|
return filled($this->accountOwner) && filled($this->accountIban);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class ReleaseRefundController extends CommonController
|
|||||||
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
|
amount: Amount::fromString((string) $request->input('amount'), 'Euro'),
|
||||||
reason: (string) $request->input('reason'),
|
reason: (string) $request->input('reason'),
|
||||||
reasonNote: Text::nullIfBlank($request->input('reasonNote')),
|
reasonNote: Text::nullIfBlank($request->input('reasonNote')),
|
||||||
|
// Leer, wenn der Teili die Bankverbindung selbst eintragen soll.
|
||||||
|
accountOwner: Text::nullIfBlank($request->input('accountOwner')),
|
||||||
|
accountIban: Text::nullIfBlank($request->input('accountIban')),
|
||||||
);
|
);
|
||||||
|
|
||||||
$response = new ReleaseRefundCommand($refundRequest)->execute();
|
$response = new ReleaseRefundCommand($refundRequest)->execute();
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ final class ParticipantRefundTokens
|
|||||||
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
|
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
|
||||||
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
|
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
|
||||||
'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
|
'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
|
||||||
|
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'body' => [
|
'body' => [
|
||||||
|
|||||||
@@ -21,8 +21,34 @@ class InvoiceType extends CommonModel {
|
|||||||
|
|
||||||
public const INVOICE_TYPE_MANAGEMENT = 'management';
|
public const INVOICE_TYPE_MANAGEMENT = 'management';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstattung eines Teilnahmebeitrags. Entsteht ausschließlich aus einem bestätigten
|
||||||
|
* Erstattungsvorgang und ist deshalb nicht von Hand wählbar ({@see self::selectable()}).
|
||||||
|
*/
|
||||||
|
public const INVOICE_TYPE_PARTICIPATION_REFUND = 'participation_refund';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'slug',
|
'slug',
|
||||||
'name',
|
'name',
|
||||||
|
'sort_order',
|
||||||
|
'selectable',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'selectable' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Typen, die in einem Formular zur Auswahl stehen dürfen -- nach Sortierung.
|
||||||
|
*
|
||||||
|
* Automatisch vergebene Typen bleiben außen vor: Ihre Abrechnungen entstehen aus einem Vorgang, der
|
||||||
|
* die Daten mitbringt; von Hand gewählt stünde ein leerer Rahmen ohne diesen Vorgang da.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function selectable(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return self::where('selectable', true)->orderBy('sort_order')->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class RefundAcceptedMail extends Mailable
|
|||||||
public function content(): Content
|
public function content(): Content
|
||||||
{
|
{
|
||||||
$event = $this->participant->event()->first();
|
$event = $this->participant->event()->first();
|
||||||
|
$invoice = $this->refund->invoice()->first();
|
||||||
|
|
||||||
return new Content(
|
return new Content(
|
||||||
view: 'emails.events.refund_accepted',
|
view: 'emails.events.refund_accepted',
|
||||||
@@ -52,6 +53,13 @@ class RefundAcceptedMail extends Mailable
|
|||||||
'accountOwner' => $this->refund->account_owner,
|
'accountOwner' => $this->refund->account_owner,
|
||||||
'accountIban' => Iban::format((string) $this->refund->account_iban),
|
'accountIban' => Iban::format((string) $this->refund->account_iban),
|
||||||
'hasDocument' => $this->pdfContent !== null,
|
'hasDocument' => $this->pdfContent !== null,
|
||||||
|
'invoiceNumber' => $invoice?->invoice_number,
|
||||||
|
// Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts
|
||||||
|
// eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken.
|
||||||
|
'capturedByManagement' => $this->refund->wasCapturedByManagement(),
|
||||||
|
// Der Hinweis auf "Meine Abrechnungen" nur, wenn die Anmeldung an einem Konto hängt --
|
||||||
|
// die Seite filtert über die Nutzer-Verknüpfung und bliebe sonst leer.
|
||||||
|
'myInvoicesUrl' => $invoice?->user_id !== null ? url('/invoice/my-invoices/new') : null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
* @property string|null $reason_note
|
* @property string|null $reason_note
|
||||||
* @property string|null $account_owner
|
* @property string|null $account_owner
|
||||||
* @property string|null $account_iban
|
* @property string|null $account_iban
|
||||||
|
* @property int|null $captured_by
|
||||||
|
* @property int|null $invoice_id
|
||||||
* @property int|null $released_by
|
* @property int|null $released_by
|
||||||
* @property \Illuminate\Support\Carbon|null $released_at
|
* @property \Illuminate\Support\Carbon|null $released_at
|
||||||
* @property \Illuminate\Support\Carbon|null $accepted_at
|
* @property \Illuminate\Support\Carbon|null $accepted_at
|
||||||
@@ -51,6 +53,8 @@ class ParticipantRefund extends InstancedModel
|
|||||||
'reason_note',
|
'reason_note',
|
||||||
'account_owner',
|
'account_owner',
|
||||||
'account_iban',
|
'account_iban',
|
||||||
|
'captured_by',
|
||||||
|
'invoice_id',
|
||||||
'released_by',
|
'released_by',
|
||||||
'released_at',
|
'released_at',
|
||||||
'accepted_at',
|
'accepted_at',
|
||||||
@@ -82,6 +86,29 @@ class ParticipantRefund extends InstancedModel
|
|||||||
return $this->belongsTo(RefundReason::class, 'reason', 'slug');
|
return $this->belongsTo(RefundReason::class, 'reason', 'slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Abrechnung, die aus diesem Vorgang entstanden ist. Der Auszahlungsstand steht dort und wird
|
||||||
|
* hier nicht gedoppelt.
|
||||||
|
*/
|
||||||
|
public function invoice(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Invoice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer die Bankverbindung aufgenommen hat -- leer, wenn der Teili sie selbst eingetragen hat.
|
||||||
|
*/
|
||||||
|
public function capturedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'captured_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob die Angaben von der Aktionsleitung stammen und nicht vom Teili selbst. */
|
||||||
|
public function wasCapturedByManagement(): bool
|
||||||
|
{
|
||||||
|
return $this->captured_by !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public function isPending(): bool
|
public function isPending(): bool
|
||||||
{
|
{
|
||||||
return $this->status === self::STATUS_PENDING;
|
return $this->status === self::STATUS_PENDING;
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ class GlobalDataProvider {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Typen für die Neuanlage durch Nutzer*innen. Reisekosten haben ein eigenes Formular. */
|
||||||
public function getInvoiceTypes() : JsonResponse {
|
public function getInvoiceTypes() : JsonResponse {
|
||||||
$invoiceTypes = [];
|
$invoiceTypes = [];
|
||||||
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
|
foreach (InvoiceType::selectable() as $invoiceType) {
|
||||||
if (
|
if (
|
||||||
$invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING
|
$invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING
|
||||||
) {
|
) {
|
||||||
@@ -110,9 +111,15 @@ class GlobalDataProvider {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Typen zum Umbuchen durch die Kassenwart*innen -- „Sonstige Kosten" ans Ende.
|
||||||
|
*
|
||||||
|
* Auch hier nur wählbare Typen: sonst ließe sich eine beliebige Abrechnung nachträglich zu einer
|
||||||
|
* Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
|
||||||
|
*/
|
||||||
public function getAllInvoiceTypes() : JsonResponse {
|
public function getAllInvoiceTypes() : JsonResponse {
|
||||||
$invoiceTypes = [];
|
$invoiceTypes = [];
|
||||||
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
|
foreach (InvoiceType::selectable() as $invoiceType) {
|
||||||
if (
|
if (
|
||||||
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
|
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ class UploadFileProvider {
|
|||||||
$this->costUnit = $costUnit;
|
$this->costUnit = $costUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Ablageverzeichnis der Belege einer Kostenstelle, relativ zur Disk `local`
|
||||||
|
* (Wurzel `storage/app/private`).
|
||||||
|
*
|
||||||
|
* Öffentlich, weil Belege nicht nur aus einem Upload entstehen: Eine Beitragserstattung erzeugt ihren
|
||||||
|
* Eigenbeleg im Speicher und legt ihn über den FileWriteProvider ab -- landen soll er trotzdem dort,
|
||||||
|
* wo alle anderen Belege liegen.
|
||||||
|
*/
|
||||||
|
public static function directoryFor(CostUnit $costUnit) : string {
|
||||||
|
return sprintf('%1$s/invoices/%2$s', app('tenant')->slug, $costUnit->id);
|
||||||
|
}
|
||||||
|
|
||||||
public function saveUploadedFile() : ?InvoiceFile {
|
public function saveUploadedFile() : ?InvoiceFile {
|
||||||
try {
|
try {
|
||||||
$directory = sprintf(
|
$directory = self::directoryFor($this->costUnit);
|
||||||
'%1$s/invoices/%2$s',
|
|
||||||
app('tenant')->slug,
|
|
||||||
$this->costUnit->id
|
|
||||||
);
|
|
||||||
|
|
||||||
$filename = $this->normalizeFilename($this->file->getClientOriginalName());
|
$filename = $this->normalizeFilename($this->file->getClientOriginalName());
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class ParticipantRefundResource extends JsonResource
|
|||||||
'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
|
'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
|
||||||
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
|
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
|
||||||
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
|
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
|
||||||
|
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
|
||||||
|
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Auslagentyp für automatisch eingereichte Beitragserstattungen.
|
||||||
|
*
|
||||||
|
* `invoices.type` ist ein Fremdschlüssel auf `invoice_types.slug` -- ohne diese Zeile scheitert schon das
|
||||||
|
* Anlegen der Abrechnung. Da `invoice_types` im Repository nirgends geseedet wird, ist die Migration der
|
||||||
|
* einzige verlässliche Weg; sie schreibt nur, was noch fehlt.
|
||||||
|
*
|
||||||
|
* Zur neuen Spalte `selectable`: Der Typ entsteht ausschließlich aus einem Erstattungsvorgang und darf in
|
||||||
|
* keinem Formular zur Auswahl stehen. Bisher wurden solche Ausnahmen als hartkodierte `continue`-Zweige
|
||||||
|
* in GlobalDataProvider gelöst (dort für `travelling`) -- eine dritte Sonderregel an zwei Stellen wäre die
|
||||||
|
* Sorte Code, die beim nächsten Typ wieder wächst. Das Flag sagt selbst, was es tut.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
private const string SLUG = 'participation_refund';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('invoice_types', function (Blueprint $table) {
|
||||||
|
$table->boolean('selectable')->default(true)->after('sort_order');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DB::table('invoice_types')->where('slug', self::SLUG)->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('invoice_types')->insert([
|
||||||
|
'slug' => self::SLUG,
|
||||||
|
'name' => 'Beitragserstattung',
|
||||||
|
// Hinter "Verwaltung" (2) und vor "Sonstige Kosten" (3) wäre die Reihenfolge unklar; der Typ
|
||||||
|
// taucht ohnehin nur in Summen und Auswertungen auf.
|
||||||
|
'sort_order' => 4,
|
||||||
|
'selectable' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Nur löschen, wenn keine Abrechnung daran hängt -- der Fremdschlüssel würde es sonst verhindern,
|
||||||
|
// und ein Rollback soll nicht an fremden Daten scheitern.
|
||||||
|
if (!DB::table('invoices')->where('type', self::SLUG)->exists()) {
|
||||||
|
DB::table('invoice_types')->where('slug', self::SLUG)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('invoice_types', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('selectable');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindet den Erstattungsvorgang mit der Abrechnung, die aus ihm entstanden ist.
|
||||||
|
*
|
||||||
|
* Damit liest die Aktionsleitung den Auszahlungsstand dort ab, wo er entsteht -- in der Abrechnung --,
|
||||||
|
* statt ihn am Vorgang zu doppeln und synchron halten zu müssen.
|
||||||
|
*
|
||||||
|
* `nullOnDelete`: wird eine Abrechnung gelöscht, bleibt der Erstattungsvorgang bestehen. Er ist die
|
||||||
|
* Erklärung des Teilis und hat einen eigenen Beleg; er hängt nicht am Leben der Abrechnung.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('participant_refunds', function (Blueprint $table) {
|
||||||
|
$table->foreignId('invoice_id')->nullable()->after('account_iban')
|
||||||
|
->constrained('invoices', 'id')->nullOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('participant_refunds', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['invoice_id']);
|
||||||
|
$table->dropColumn('invoice_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält fest, wer die Bankverbindung erfasst hat.
|
||||||
|
*
|
||||||
|
* `null` heißt: der Teili hat sie selbst über den Token-Link eingetragen und dabei die Erklärung
|
||||||
|
* angekreuzt. Ist die Spalte gesetzt, hat die Aktionsleitung die Angaben aufgenommen, weil sie ihr schon
|
||||||
|
* vorlagen -- der Beleg weist das dann samt Namen aus, damit niemand die Erklärung für eine Bestätigung
|
||||||
|
* des Teilis hält.
|
||||||
|
*
|
||||||
|
* Kein zusätzliches `captured_at`: Der Zeitpunkt ist `accepted_at`, der in diesem Fall mit der Freigabe
|
||||||
|
* zusammenfällt.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('participant_refunds', function (Blueprint $table) {
|
||||||
|
$table->foreignId('captured_by')->nullable()->after('account_iban')
|
||||||
|
->constrained('users', 'id')->nullOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('participant_refunds', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['captured_by']);
|
||||||
|
$table->dropColumn('captured_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -84,6 +84,9 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
|
|||||||
/* Die Versicherung des Teilis unter den Angaben */
|
/* Die Versicherung des Teilis unter den Angaben */
|
||||||
.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; }
|
.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; }
|
||||||
|
|
||||||
|
/* Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat -- steht sonst nicht auf dem Beleg. */
|
||||||
|
.capture-note { font-size: 8pt; color: #555; line-height: 1.55; margin-top: 3mm; }
|
||||||
|
|
||||||
/* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */
|
/* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */
|
||||||
.edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()),
|
.edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()),
|
||||||
('participant_refund', 'header_sender_return', '<div class="absender-rueck">{sender_name}{if:sender_address_1} · {sender_address_1}{/if:sender_address_1} · {sender_postcode} {sender_city}</div>', 30, 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()),
|
||||||
@@ -130,5 +133,6 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
|
|||||||
|
|
||||||
{details_table}
|
{details_table}
|
||||||
|
|
||||||
<div class="declaration">{declaration_text}</div>', 90, 1, NOW(), NOW()),
|
<div class="declaration">{declaration_text}</div>
|
||||||
|
{if:capture_note}<div class="capture-note">{capture_note}</div>{/if:capture_note}', 90, 1, NOW(), NOW()),
|
||||||
('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
|
('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
|
||||||
|
|||||||
@@ -2,10 +2,22 @@
|
|||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>Hallo {{$name}}!</h1>
|
<h1>Hallo {{$name}}!</h1>
|
||||||
<p>
|
@if ($capturedByManagement)
|
||||||
|
<p>
|
||||||
|
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
|
||||||
|
Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die
|
||||||
|
Erstattung ist bereits als Abrechnung eingereicht und wird nun bearbeitet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Bitte prüfe die unten stehenden Angaben</strong> – besonders die IBAN.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<p>
|
||||||
vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
|
vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen
|
||||||
uns vor.
|
uns vor. <strong>Du musst nichts weiter tun</strong>: Die Erstattung ist bereits als Abrechnung
|
||||||
</p>
|
eingereicht und wird nun bearbeitet.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<table style="border-collapse: collapse; margin: 16px 0;">
|
<table style="border-collapse: collapse; margin: 16px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -16,6 +28,12 @@
|
|||||||
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
|
<td style="padding: 4px 16px 4px 0; color: #555;">Grund:</td>
|
||||||
<td style="padding: 4px 0;">{{$reason}}</td>
|
<td style="padding: 4px 0;">{{$reason}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if ($invoiceNumber)
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 16px 4px 0; color: #555;">Abrechnungsnummer:</td>
|
||||||
|
<td style="padding: 4px 0;">{{$invoiceNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
|
<td style="padding: 4px 16px 4px 0; color: #555;">Kontoinhaber*in:</td>
|
||||||
<td style="padding: 4px 0;">{{$accountOwner}}</td>
|
<td style="padding: 4px 0;">{{$accountOwner}}</td>
|
||||||
@@ -28,13 +46,21 @@
|
|||||||
|
|
||||||
@if ($hasDocument)
|
@if ($hasDocument)
|
||||||
<p>
|
<p>
|
||||||
Im Anhang findest du den Beleg über die Rückerstattung als PDF.
|
Im Anhang findest du deinen Beleg über die Rückerstattung als PDF – nur zu deiner
|
||||||
|
Information, eingereicht ist er bereits.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($myInvoicesUrl)
|
||||||
|
<p>
|
||||||
|
Den Stand deiner Abrechnung kannst du jederzeit selbst nachsehen: Melde dich in mareike an und
|
||||||
|
öffne <a href="{{$myInvoicesUrl}}">Meine Abrechnungen</a>.
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde
|
Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
|
||||||
dich bitte umgehend bei ihr.
|
Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enumerations\InvoiceType;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Auslagentypen zur Auswahl stehen.
|
||||||
|
*
|
||||||
|
* Die Beitragserstattung entsteht ausschließlich aus einem bestätigten Erstattungsvorgang, der die
|
||||||
|
* Bankverbindung, den Beleg und den Bezug zur Anmeldung mitbringt. Von Hand gewählt stünde ein leerer
|
||||||
|
* Rahmen ohne diesen Vorgang da -- deshalb darf sie in keinem Formular auftauchen.
|
||||||
|
*/
|
||||||
|
class InvoiceTypeSelectionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'slug' => 'wm',
|
||||||
|
'name' => 'Wilde Möhre',
|
||||||
|
'email' => 'wm@example.com',
|
||||||
|
'email_finance' => 'wm-f@example.com',
|
||||||
|
'url' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||||
|
'account_name' => 'Wilde Möhre e.V.',
|
||||||
|
'account_iban' => 'DE00',
|
||||||
|
'account_bic' => 'XY',
|
||||||
|
'city' => 'Stadt',
|
||||||
|
'postcode' => '00000',
|
||||||
|
'is_active_local_group' => true,
|
||||||
|
'has_active_instance' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance('tenant', $tenant);
|
||||||
|
|
||||||
|
// Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt.
|
||||||
|
DB::table('invoice_types')->insert([
|
||||||
|
['slug' => InvoiceType::INVOICE_TYPE_PROGRAM, 'name' => 'Programmkosten', 'sort_order' => 1, 'selectable' => true],
|
||||||
|
['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten', 'sort_order' => 3, 'selectable' => true],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_refund_type_exists_but_is_not_selectable(): void
|
||||||
|
{
|
||||||
|
// `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`.
|
||||||
|
$type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($type, 'Der Typ muss existieren -- invoices.type ist ein Fremdschlüssel darauf.');
|
||||||
|
$this->assertFalse($type->selectable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_is_missing_from_the_new_invoice_form(): void
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/api/v1/core/retrieve-invoice-types');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$slugs = array_column($response->json('invoiceTypes'), 'slug');
|
||||||
|
|
||||||
|
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
|
||||||
|
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
|
||||||
|
$this->assertContains(InvoiceType::INVOICE_TYPE_OTHER, $slugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_is_missing_when_a_treasurer_rebooks(): void
|
||||||
|
{
|
||||||
|
// Ohne diese Sperre ließe sich jede beliebige Abrechnung nachträglich zu einer
|
||||||
|
// Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde.
|
||||||
|
$response = $this->getJson('/api/v1/core/retrieve-invoice-types-all');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$slugs = array_column($response->json('invoiceTypes'), 'slug');
|
||||||
|
|
||||||
|
$this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs);
|
||||||
|
$this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs);
|
||||||
|
// "Sonstige Kosten" steht dort bewusst am Ende.
|
||||||
|
$this->assertSame(InvoiceType::INVOICE_TYPE_OTHER, end($slugs));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_selectable_returns_only_choosable_types_in_order(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
[InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
|
||||||
|
InvoiceType::selectable()->pluck('slug')->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,14 @@ use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocum
|
|||||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
||||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
||||||
use App\Enumerations\EfzStatus;
|
use App\Enumerations\EfzStatus;
|
||||||
|
use App\Enumerations\CostUnitType;
|
||||||
|
use App\Enumerations\InvoiceStatus;
|
||||||
|
use App\Enumerations\InvoiceType;
|
||||||
use App\Enumerations\RefundReason;
|
use App\Enumerations\RefundReason;
|
||||||
use App\Enumerations\UserRole;
|
use App\Enumerations\UserRole;
|
||||||
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
||||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||||
|
use App\Models\CostUnit;
|
||||||
use App\Models\DocumentTemplate;
|
use App\Models\DocumentTemplate;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventParticipant;
|
use App\Models\EventParticipant;
|
||||||
@@ -67,6 +71,10 @@ class ParticipantRefundTest extends TestCase
|
|||||||
|
|
||||||
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
||||||
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
||||||
|
// Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen.
|
||||||
|
// `invoice_types.participation_refund` bringt die Migration bereits mit.
|
||||||
|
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
|
||||||
|
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
||||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
|
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
|
||||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
||||||
@@ -112,6 +120,20 @@ class ParticipantRefundTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */
|
||||||
|
private function makeCostUnit(array $attributes = []): CostUnit
|
||||||
|
{
|
||||||
|
return CostUnit::create(array_merge([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'name' => 'Sommerlager',
|
||||||
|
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
|
||||||
|
'distance_allowance' => 0.25,
|
||||||
|
'mail_on_new' => false,
|
||||||
|
'allow_new' => true,
|
||||||
|
'archived' => false,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
private function makeEvent(array $attributes = []): Event
|
private function makeEvent(array $attributes = []): Event
|
||||||
{
|
{
|
||||||
$fee = EventParticipationFee::create([
|
$fee = EventParticipationFee::create([
|
||||||
@@ -125,6 +147,7 @@ class ParticipantRefundTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return Event::create(array_merge([
|
return Event::create(array_merge([
|
||||||
|
'cost_unit_id' => $this->makeCostUnit()->id,
|
||||||
'tenant' => $this->tenant->slug,
|
'tenant' => $this->tenant->slug,
|
||||||
'name' => 'Sommerlager',
|
'name' => 'Sommerlager',
|
||||||
'identifier' => 'evt-' . uniqid(),
|
'identifier' => 'evt-' . uniqid(),
|
||||||
@@ -532,18 +555,32 @@ class ParticipantRefundTest extends TestCase
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function test_amount_paid_is_untouched_through_the_whole_process(): void
|
public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void
|
||||||
{
|
{
|
||||||
$participant = $this->makeParticipant($this->makeEvent());
|
$participant = $this->makeParticipant($this->makeEvent());
|
||||||
|
|
||||||
|
// Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag
|
||||||
|
// unverändert beim Verband.
|
||||||
$refund = $this->release($participant)->refund;
|
$refund = $this->release($participant)->refund;
|
||||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
|
||||||
$this->accept($refund);
|
$this->accept($refund);
|
||||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
|
||||||
|
// Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
|
||||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
|
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_a_cancelled_release_leaves_amount_paid_alone(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent());
|
||||||
|
$refund = $this->release($participant)->refund;
|
||||||
|
|
||||||
|
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
|
||||||
|
|
||||||
|
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
|
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
|
||||||
@@ -565,6 +602,22 @@ class ParticipantRefundTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */
|
||||||
|
private function makeParticipantUser(): User
|
||||||
|
{
|
||||||
|
return User::create([
|
||||||
|
'username' => 'teili-' . uniqid() . '@example.com',
|
||||||
|
'email' => 'teili-' . uniqid() . '@example.com',
|
||||||
|
'firstname' => 'Mika',
|
||||||
|
'lastname' => 'Muster',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'local_group' => $this->tenant->slug,
|
||||||
|
'user_role_main' => UserRole::USER_ROLE_USER,
|
||||||
|
'user_role_local_group' => UserRole::USER_ROLE_USER,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_release_over_http_creates_the_refund(): void
|
public function test_release_over_http_creates_the_refund(): void
|
||||||
{
|
{
|
||||||
$this->actingAs($this->makeAdmin());
|
$this->actingAs($this->makeAdmin());
|
||||||
@@ -753,6 +806,40 @@ class ParticipantRefundTest extends TestCase
|
|||||||
$this->assertStringContainsString('Anhang', $html);
|
$this->assertStringContainsString('Anhang', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_the_acceptance_mail_points_at_the_invoice(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]);
|
||||||
|
$refund = $this->release($participant)->refund;
|
||||||
|
$this->accept($refund);
|
||||||
|
|
||||||
|
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
|
||||||
|
|
||||||
|
// Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits.
|
||||||
|
$this->assertStringContainsString('musst nichts weiter tun', $html);
|
||||||
|
$this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html);
|
||||||
|
$this->assertStringContainsString('/invoice/my-invoices/new', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void
|
||||||
|
{
|
||||||
|
// Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung.
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
|
||||||
|
$refund = $this->release($participant)->refund;
|
||||||
|
$this->accept($refund);
|
||||||
|
|
||||||
|
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('/invoice/my-invoices/new', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_empty_second_address_gets_no_mail(): void
|
||||||
|
{
|
||||||
|
// Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL.
|
||||||
|
$this->release($this->makeParticipant($this->makeEvent(), ['email_2' => '']));
|
||||||
|
|
||||||
|
Mail::assertSent(RefundReleasedMail::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_the_acceptance_mail_renders_without_a_document(): void
|
public function test_the_acceptance_mail_renders_without_a_document(): void
|
||||||
{
|
{
|
||||||
$participant = $this->makeParticipant($this->makeEvent());
|
$participant = $this->makeParticipant($this->makeEvent());
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
||||||
|
use App\Enumerations\CostUnitType;
|
||||||
|
use App\Enumerations\EfzStatus;
|
||||||
|
use App\Enumerations\InvoiceStatus;
|
||||||
|
use App\Enumerations\RefundReason;
|
||||||
|
use App\Enumerations\UserRole;
|
||||||
|
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
||||||
|
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||||
|
use App\Models\CostUnit;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventParticipant;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\ParticipantRefund;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\RelationModels\EventParticipationFee;
|
||||||
|
use App\ValueObjects\Amount;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Direktweg: Liegt der Aktionsleitung die Bankverbindung bereits vor, entfällt der Umweg über den
|
||||||
|
* Teili -- die Erstattung wird sofort eingereicht.
|
||||||
|
*/
|
||||||
|
class RefundDirectCaptureTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private User $management;
|
||||||
|
|
||||||
|
private int $sequence = 0;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->tenant = Tenant::create([
|
||||||
|
'slug' => 'wm',
|
||||||
|
'name' => 'Wilde Möhre',
|
||||||
|
'address_1' => 'Musterweg 1',
|
||||||
|
'email' => 't@example.com',
|
||||||
|
'email_finance' => 'finance@example.com',
|
||||||
|
'url' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||||
|
'account_name' => 'Test e.V.',
|
||||||
|
'account_iban' => 'DE00',
|
||||||
|
'account_bic' => 'XY',
|
||||||
|
'city' => 'Stadt',
|
||||||
|
'postcode' => '00000',
|
||||||
|
'invoice_prefix' => 'WM',
|
||||||
|
'is_active_local_group' => true,
|
||||||
|
'has_active_instance' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance('tenant', $this->tenant);
|
||||||
|
|
||||||
|
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
||||||
|
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
||||||
|
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
|
||||||
|
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
||||||
|
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||||
|
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
||||||
|
|
||||||
|
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
|
||||||
|
UserRole::create(['slug' => $role, 'name' => $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->seedTemplate();
|
||||||
|
|
||||||
|
// Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang.
|
||||||
|
$this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN);
|
||||||
|
$this->actingAs($this->management);
|
||||||
|
|
||||||
|
Storage::fake('local');
|
||||||
|
Mail::fake();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTemplate(): void
|
||||||
|
{
|
||||||
|
DocumentTemplate::create([
|
||||||
|
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||||
|
'block' => DocumentTemplate::BLOCK_LAYOUT,
|
||||||
|
'content' => '<div>{block:body}</div>',
|
||||||
|
'sort_order' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DocumentTemplate::create([
|
||||||
|
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||||
|
'block' => DocumentTemplate::BLOCK_BODY,
|
||||||
|
'content' => '{details_table}<p>{declaration_text}</p>'
|
||||||
|
. '{if:capture_note}<p class="capture-note">{capture_note}</p>{/if:capture_note}',
|
||||||
|
'sort_order' => 20,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(string $firstname, string $lastname, string $role): User
|
||||||
|
{
|
||||||
|
return User::create([
|
||||||
|
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
|
||||||
|
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
|
||||||
|
'firstname' => $firstname,
|
||||||
|
'lastname' => $lastname,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'local_group' => $this->tenant->slug,
|
||||||
|
'user_role_main' => $role,
|
||||||
|
'user_role_local_group' => UserRole::USER_ROLE_USER,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(array $attributes = []): Event
|
||||||
|
{
|
||||||
|
$fee = EventParticipationFee::create([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'type' => 'participant',
|
||||||
|
'name' => 'Sippe',
|
||||||
|
'description' => null,
|
||||||
|
'amount_standard' => 60.0,
|
||||||
|
'amount_reduced' => null,
|
||||||
|
'amount_solidarity' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$costUnit = CostUnit::create([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'name' => 'Sommerlager',
|
||||||
|
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
|
||||||
|
'distance_allowance' => 0.25,
|
||||||
|
'mail_on_new' => false,
|
||||||
|
'allow_new' => true,
|
||||||
|
'archived' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Event::create(array_merge([
|
||||||
|
'cost_unit_id' => $costUnit->id,
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'name' => 'Sommerlager',
|
||||||
|
'identifier' => 'evt-' . uniqid(),
|
||||||
|
'location' => 'Ort',
|
||||||
|
'postal_code' => '00000',
|
||||||
|
'email' => 'e@example.com',
|
||||||
|
'start_date' => '2026-07-16',
|
||||||
|
'end_date' => '2026-07-20',
|
||||||
|
'early_bird_end' => '2026-06-20',
|
||||||
|
'registration_final_end' => '2026-07-01',
|
||||||
|
'early_bird_end_amount_increase' => 0,
|
||||||
|
'account_owner' => 'Owner',
|
||||||
|
'account_iban' => 'DE00',
|
||||||
|
'participation_fee_type' => 'fixed',
|
||||||
|
'participation_fee_1' => $fee->id,
|
||||||
|
'pay_per_day' => true,
|
||||||
|
'pay_direct' => false,
|
||||||
|
'tax_liable' => false,
|
||||||
|
'vat_rate' => 0,
|
||||||
|
'vat_pricing_mode' => 'inclusive',
|
||||||
|
'invoice_key' => 'WM-V-20260701',
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant
|
||||||
|
{
|
||||||
|
$event ??= $this->makeEvent();
|
||||||
|
$this->sequence++;
|
||||||
|
|
||||||
|
return $event->participants()->create(array_merge([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'identifier' => 'p-' . uniqid(),
|
||||||
|
'invoice_sequence' => $this->sequence,
|
||||||
|
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
|
||||||
|
'firstname' => 'Mika',
|
||||||
|
'lastname' => 'Muster',
|
||||||
|
'participation_type' => 'participant',
|
||||||
|
'fee_type' => 'standard',
|
||||||
|
'sibling_reduction' => false,
|
||||||
|
'local_group' => $this->tenant->slug,
|
||||||
|
'birthday' => '2000-01-01',
|
||||||
|
'address_1' => 'Beispielstraße 3',
|
||||||
|
'postcode' => '11111',
|
||||||
|
'city' => 'Beispielstadt',
|
||||||
|
'email_1' => 'mika@example.com',
|
||||||
|
'phone_1' => '0170 0000000',
|
||||||
|
'arrival_date' => '2026-07-16',
|
||||||
|
'departure_date' => '2026-07-20',
|
||||||
|
'arrival_eating' => 1,
|
||||||
|
'departure_eating' => 1,
|
||||||
|
'amount' => 300.0,
|
||||||
|
'amount_paid' => 300.0,
|
||||||
|
'unregistered_at' => '2026-06-12',
|
||||||
|
'payment_purpose' => 'Sommerlager',
|
||||||
|
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||||
|
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freigabe mit bereits bekannter Bankverbindung. */
|
||||||
|
private function releaseWithBankDetails(
|
||||||
|
?EventParticipant $participant = null,
|
||||||
|
string $owner = 'Mika Muster',
|
||||||
|
string $iban = 'DE02120300000000202051',
|
||||||
|
float $amount = 220.0,
|
||||||
|
) {
|
||||||
|
return new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||||
|
participant: $participant ?? $this->makeParticipant(),
|
||||||
|
amount: new Amount($amount, 'Euro'),
|
||||||
|
reason: RefundReason::SICKNESS,
|
||||||
|
accountOwner: $owner,
|
||||||
|
accountIban: $iban,
|
||||||
|
))->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Der Vorgang ist sofort abgeschlossen
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_the_refund_is_submitted_right_away(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant();
|
||||||
|
|
||||||
|
$response = $this->releaseWithBankDetails($participant);
|
||||||
|
|
||||||
|
$this->assertTrue($response->success);
|
||||||
|
$this->assertStringContainsString('eingereicht', $response->message);
|
||||||
|
|
||||||
|
$refund = ParticipantRefund::first();
|
||||||
|
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
|
||||||
|
$this->assertSame('Mika Muster', $refund->account_owner);
|
||||||
|
$this->assertSame('DE02120300000000202051', $refund->account_iban);
|
||||||
|
$this->assertNotNull($refund->accepted_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_capturing_person_is_recorded(): void
|
||||||
|
{
|
||||||
|
$this->releaseWithBankDetails();
|
||||||
|
|
||||||
|
$refund = ParticipantRefund::first();
|
||||||
|
|
||||||
|
$this->assertSame($this->management->id, $refund->captured_by);
|
||||||
|
$this->assertTrue($refund->wasCapturedByManagement());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant();
|
||||||
|
|
||||||
|
$this->releaseWithBankDetails($participant);
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
$this->assertNotNull($invoice);
|
||||||
|
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
|
||||||
|
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
|
||||||
|
|
||||||
|
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_only_the_receipt_mail_goes_out(): void
|
||||||
|
{
|
||||||
|
$this->releaseWithBankDetails();
|
||||||
|
|
||||||
|
// Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos.
|
||||||
|
Mail::assertNotSent(RefundReleasedMail::class);
|
||||||
|
Mail::assertSent(RefundAcceptedMail::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Der Beleg weist aus, wer die Angaben aufgenommen hat
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_the_receipt_names_who_captured_the_details(): void
|
||||||
|
{
|
||||||
|
$this->releaseWithBankDetails();
|
||||||
|
|
||||||
|
$html = $this->renderReceipt(ParticipantRefund::first());
|
||||||
|
|
||||||
|
// Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst.
|
||||||
|
$this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html);
|
||||||
|
$this->assertStringContainsString('Ich versichere', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void
|
||||||
|
{
|
||||||
|
// Gegenprobe: der gewöhnliche Weg bleibt unverändert.
|
||||||
|
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||||
|
participant: $this->makeParticipant(),
|
||||||
|
amount: new Amount(220.0, 'Euro'),
|
||||||
|
reason: RefundReason::SICKNESS,
|
||||||
|
))->execute()->refund;
|
||||||
|
|
||||||
|
$refund->update([
|
||||||
|
'status' => ParticipantRefund::STATUS_ACCEPTED,
|
||||||
|
'account_owner' => 'Mika Muster',
|
||||||
|
'account_iban' => 'DE02120300000000202051',
|
||||||
|
'accepted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderReceipt(ParticipantRefund $refund): string
|
||||||
|
{
|
||||||
|
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
|
||||||
|
$number = new \ReflectionMethod($command, 'documentNumber')->invoke($command);
|
||||||
|
$tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
|
||||||
|
|
||||||
|
return new \App\Providers\DocumentTemplateRenderProvider(
|
||||||
|
DocumentTemplate::TYPE_PARTICIPANT_REFUND
|
||||||
|
)->render($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_an_invalid_iban_stops_everything(): void
|
||||||
|
{
|
||||||
|
// Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher.
|
||||||
|
$response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015');
|
||||||
|
|
||||||
|
$this->assertFalse($response->success);
|
||||||
|
$this->assertStringContainsString('IBAN', $response->message);
|
||||||
|
$this->assertSame(0, ParticipantRefund::count());
|
||||||
|
$this->assertSame(0, Invoice::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_half_filled_bank_details_are_refused(): void
|
||||||
|
{
|
||||||
|
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||||
|
participant: $this->makeParticipant(),
|
||||||
|
amount: new Amount(220.0, 'Euro'),
|
||||||
|
reason: RefundReason::SICKNESS,
|
||||||
|
accountOwner: 'Mika Muster',
|
||||||
|
))->execute();
|
||||||
|
|
||||||
|
$this->assertFalse($response->success);
|
||||||
|
$this->assertSame(0, ParticipantRefund::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_a_cost_unit_nothing_is_created(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
|
||||||
|
|
||||||
|
$response = $this->releaseWithBankDetails($participant);
|
||||||
|
|
||||||
|
$this->assertFalse($response->success);
|
||||||
|
$this->assertStringContainsString('Kostenstelle', $response->message);
|
||||||
|
$this->assertSame(0, ParticipantRefund::count());
|
||||||
|
$this->assertSame(0, Invoice::count());
|
||||||
|
// Der gezahlte Beitrag bleibt unangetastet.
|
||||||
|
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Über HTTP
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_release_over_http_submits_directly(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||||
|
'amount' => '220,00',
|
||||||
|
'reason' => RefundReason::SICKNESS,
|
||||||
|
'accountOwner' => 'Mika Muster',
|
||||||
|
'accountIban' => 'DE02 1203 0000 0000 2020 51',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('status', 'success')
|
||||||
|
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED);
|
||||||
|
|
||||||
|
$this->assertNotNull(ParticipantRefund::first()->invoice_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||||
|
'amount' => '220,00',
|
||||||
|
'reason' => RefundReason::SICKNESS,
|
||||||
|
'accountOwner' => '',
|
||||||
|
'accountIban' => '',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
|
||||||
|
|
||||||
|
Mail::assertSent(RefundReleasedMail::class);
|
||||||
|
$this->assertSame(0, Invoice::count());
|
||||||
|
$this->assertNull(ParticipantRefund::first()->captured_by);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
||||||
|
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
||||||
|
use App\Enumerations\CostUnitType;
|
||||||
|
use App\Enumerations\EfzStatus;
|
||||||
|
use App\Enumerations\InvoiceStatus;
|
||||||
|
use App\Enumerations\InvoiceType;
|
||||||
|
use App\Enumerations\RefundReason;
|
||||||
|
use App\Enumerations\UserRole;
|
||||||
|
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
|
||||||
|
use App\Models\CostUnit;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventParticipant;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\ParticipantRefund;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\RelationModels\EventParticipationFee;
|
||||||
|
use App\ValueObjects\Amount;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Anschluss an die Buchhaltung: aus einer bestätigten Erstattung wird eine gewöhnliche
|
||||||
|
* Auslagenabrechnung, die den regulären Weg bis zur SEPA-Datei geht.
|
||||||
|
*/
|
||||||
|
class RefundInvoiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private int $sequence = 0;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->tenant = Tenant::create([
|
||||||
|
'slug' => 'wm',
|
||||||
|
'name' => 'Wilde Möhre',
|
||||||
|
'address_1' => 'Musterweg 1',
|
||||||
|
'email' => 't@example.com',
|
||||||
|
'email_finance' => 'finance@example.com',
|
||||||
|
'url' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||||
|
'account_name' => 'Test e.V.',
|
||||||
|
'account_iban' => 'DE00',
|
||||||
|
'account_bic' => 'XY',
|
||||||
|
'city' => 'Stadt',
|
||||||
|
'postcode' => '00000',
|
||||||
|
'invoice_prefix' => 'WM',
|
||||||
|
'is_active_local_group' => true,
|
||||||
|
'has_active_instance' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance('tenant', $this->tenant);
|
||||||
|
|
||||||
|
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
||||||
|
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
||||||
|
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
|
||||||
|
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
|
||||||
|
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||||
|
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
||||||
|
|
||||||
|
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
|
||||||
|
UserRole::create(['slug' => $role, 'name' => $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->seedTemplate();
|
||||||
|
|
||||||
|
// Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage.
|
||||||
|
Storage::fake('local');
|
||||||
|
Mail::fake();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTemplate(): void
|
||||||
|
{
|
||||||
|
DocumentTemplate::create([
|
||||||
|
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||||
|
'block' => DocumentTemplate::BLOCK_LAYOUT,
|
||||||
|
'content' => '<div>{block:body}</div>',
|
||||||
|
'sort_order' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DocumentTemplate::create([
|
||||||
|
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||||
|
'block' => DocumentTemplate::BLOCK_BODY,
|
||||||
|
'content' => '<div>{recipient_name}</div>{details_table}',
|
||||||
|
'sort_order' => 20,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeCostUnit(array $attributes = []): CostUnit
|
||||||
|
{
|
||||||
|
return CostUnit::create(array_merge([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'name' => 'Sommerlager',
|
||||||
|
'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
|
||||||
|
'distance_allowance' => 0.25,
|
||||||
|
'mail_on_new' => false,
|
||||||
|
'allow_new' => true,
|
||||||
|
'archived' => false,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(array $attributes = []): Event
|
||||||
|
{
|
||||||
|
$fee = EventParticipationFee::create([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'type' => 'participant',
|
||||||
|
'name' => 'Sippe',
|
||||||
|
'description' => null,
|
||||||
|
'amount_standard' => 60.0,
|
||||||
|
'amount_reduced' => null,
|
||||||
|
'amount_solidarity' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Event::create(array_merge([
|
||||||
|
'cost_unit_id' => $this->makeCostUnit()->id,
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'name' => 'Sommerlager',
|
||||||
|
'identifier' => 'evt-' . uniqid(),
|
||||||
|
'location' => 'Ort',
|
||||||
|
'postal_code' => '00000',
|
||||||
|
'email' => 'e@example.com',
|
||||||
|
'start_date' => '2026-07-16',
|
||||||
|
'end_date' => '2026-07-20',
|
||||||
|
'early_bird_end' => '2026-06-20',
|
||||||
|
'registration_final_end' => '2026-07-01',
|
||||||
|
'early_bird_end_amount_increase' => 0,
|
||||||
|
'account_owner' => 'Owner',
|
||||||
|
'account_iban' => 'DE00',
|
||||||
|
'participation_fee_type' => 'fixed',
|
||||||
|
'participation_fee_1' => $fee->id,
|
||||||
|
'pay_per_day' => true,
|
||||||
|
'pay_direct' => false,
|
||||||
|
'tax_liable' => false,
|
||||||
|
'vat_rate' => 0,
|
||||||
|
'vat_pricing_mode' => 'inclusive',
|
||||||
|
'invoice_key' => 'WM-V-20260701',
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::create([
|
||||||
|
'username' => 'teili-' . uniqid() . '@example.com',
|
||||||
|
'email' => 'teili-' . uniqid() . '@example.com',
|
||||||
|
'firstname' => 'Mika',
|
||||||
|
'lastname' => 'Muster',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'local_group' => $this->tenant->slug,
|
||||||
|
'user_role_main' => UserRole::USER_ROLE_USER,
|
||||||
|
'user_role_local_group' => UserRole::USER_ROLE_USER,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeParticipant(Event $event, array $attributes = []): EventParticipant
|
||||||
|
{
|
||||||
|
$this->sequence++;
|
||||||
|
|
||||||
|
return $event->participants()->create(array_merge([
|
||||||
|
'tenant' => $this->tenant->slug,
|
||||||
|
'identifier' => 'p-' . uniqid(),
|
||||||
|
'invoice_sequence' => $this->sequence,
|
||||||
|
'user_id' => $this->makeUser()->id,
|
||||||
|
'firstname' => 'Mika',
|
||||||
|
'lastname' => 'Muster',
|
||||||
|
'participation_type' => 'participant',
|
||||||
|
'fee_type' => 'standard',
|
||||||
|
'sibling_reduction' => false,
|
||||||
|
'local_group' => $this->tenant->slug,
|
||||||
|
'birthday' => '2000-01-01',
|
||||||
|
'address_1' => 'Beispielstraße 3',
|
||||||
|
'postcode' => '11111',
|
||||||
|
'city' => 'Beispielstadt',
|
||||||
|
'email_1' => 'mika@example.com',
|
||||||
|
'phone_1' => '0170 0000000',
|
||||||
|
'arrival_date' => '2026-07-16',
|
||||||
|
'departure_date' => '2026-07-20',
|
||||||
|
'arrival_eating' => 1,
|
||||||
|
'departure_eating' => 1,
|
||||||
|
'amount' => 300.0,
|
||||||
|
'amount_paid' => 300.0,
|
||||||
|
'unregistered_at' => '2026-06-12',
|
||||||
|
'payment_purpose' => 'Sommerlager',
|
||||||
|
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||||
|
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */
|
||||||
|
private function runRefund(
|
||||||
|
?EventParticipant $participant = null,
|
||||||
|
float $amount = 220.0,
|
||||||
|
string $reason = RefundReason::SICKNESS,
|
||||||
|
): ParticipantRefund {
|
||||||
|
$participant ??= $this->makeParticipant($this->makeEvent());
|
||||||
|
|
||||||
|
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||||
|
participant: $participant,
|
||||||
|
amount: new Amount($amount, 'Euro'),
|
||||||
|
reason: $reason,
|
||||||
|
))->execute()->refund;
|
||||||
|
|
||||||
|
new AcceptRefundCommand(new AcceptRefundRequest(
|
||||||
|
refund: $refund,
|
||||||
|
accountOwner: 'Mika Muster',
|
||||||
|
accountIban: 'DE02120300000000202051',
|
||||||
|
declarationAccepted: true,
|
||||||
|
))->execute();
|
||||||
|
|
||||||
|
return $refund->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Die Abrechnung entsteht
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void
|
||||||
|
{
|
||||||
|
$event = $this->makeEvent();
|
||||||
|
$this->runRefund($this->makeParticipant($event));
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertNotNull($invoice);
|
||||||
|
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
|
||||||
|
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
|
||||||
|
$this->assertSame($event->cost_unit_id, $invoice->cost_unit_id);
|
||||||
|
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
|
||||||
|
$this->assertFalse((bool) $invoice->donation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_details_come_from_the_participant(): void
|
||||||
|
{
|
||||||
|
$this->runRefund();
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertSame('Mika Muster', $invoice->contact_name);
|
||||||
|
$this->assertSame('mika@example.com', $invoice->contact_email);
|
||||||
|
$this->assertSame('0170 0000000', $invoice->contact_phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bank_details_come_from_the_refund_not_the_participant(): void
|
||||||
|
{
|
||||||
|
$this->runRefund();
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertSame('Mika Muster', $invoice->contact_bank_owner);
|
||||||
|
$this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_invoice_belongs_to_the_participants_user_account(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent());
|
||||||
|
$this->runRefund($participant);
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertSame($participant->user_id, $invoice->user_id);
|
||||||
|
// Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id,
|
||||||
|
// sobald ein Verwendungszweck gesetzt ist.
|
||||||
|
$this->assertNull($invoice->payment_purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_anonymous_participant_still_gets_an_invoice(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
|
||||||
|
$this->runRefund($participant);
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertNotNull($invoice);
|
||||||
|
$this->assertNull($invoice->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_notice_names_event_reason_and_the_amount_paid(): void
|
||||||
|
{
|
||||||
|
$this->runRefund();
|
||||||
|
|
||||||
|
// Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird --
|
||||||
|
// sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen.
|
||||||
|
$this->assertSame(
|
||||||
|
'Rückerstattung Teilnahmebeitrag Sommerlager – Krankheitsbedingte Absage'
|
||||||
|
. ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro',
|
||||||
|
Invoice::first()->comment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_long_notice_keeps_the_amount_paid(): void
|
||||||
|
{
|
||||||
|
$event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]);
|
||||||
|
$this->runRefund($this->makeParticipant($event));
|
||||||
|
|
||||||
|
$comment = Invoice::first()->comment;
|
||||||
|
|
||||||
|
// Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen.
|
||||||
|
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment);
|
||||||
|
$this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Der Eigenbeleg hängt an der Abrechnung
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_the_receipt_is_attached_and_stored(): void
|
||||||
|
{
|
||||||
|
$event = $this->makeEvent();
|
||||||
|
$this->runRefund($this->makeParticipant($event));
|
||||||
|
|
||||||
|
$invoice = Invoice::first();
|
||||||
|
|
||||||
|
$this->assertNotNull($invoice->document_filename);
|
||||||
|
// Dasselbe Ablagemuster wie bei hochgeladenen Belegen.
|
||||||
|
$this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename);
|
||||||
|
$this->assertStringEndsWith('.pdf', $invoice->document_filename);
|
||||||
|
|
||||||
|
Storage::disk('local')->assertExists($invoice->document_filename);
|
||||||
|
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_refund_points_at_its_invoice(): void
|
||||||
|
{
|
||||||
|
$refund = $this->runRefund();
|
||||||
|
|
||||||
|
$this->assertSame(Invoice::first()->id, $refund->invoice_id);
|
||||||
|
$this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Die Kostenstelle
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_without_a_cost_unit_nothing_is_stored(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
|
||||||
|
|
||||||
|
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||||
|
participant: $participant,
|
||||||
|
amount: new Amount(220.0, 'Euro'),
|
||||||
|
reason: RefundReason::SICKNESS,
|
||||||
|
))->execute()->refund;
|
||||||
|
|
||||||
|
$response = new AcceptRefundCommand(new AcceptRefundRequest(
|
||||||
|
refund: $refund,
|
||||||
|
accountOwner: 'Mika Muster',
|
||||||
|
accountIban: 'DE02120300000000202051',
|
||||||
|
declarationAccepted: true,
|
||||||
|
))->execute();
|
||||||
|
|
||||||
|
$this->assertFalse($response->success);
|
||||||
|
$this->assertStringContainsString('Aktionsleitung', $response->message);
|
||||||
|
|
||||||
|
// Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann.
|
||||||
|
$refund->refresh();
|
||||||
|
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
|
||||||
|
$this->assertNull($refund->account_iban);
|
||||||
|
$this->assertSame(0, Invoice::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_closed_cost_unit_is_still_booked(): void
|
||||||
|
{
|
||||||
|
// Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist.
|
||||||
|
$costUnit = $this->makeCostUnit(['allow_new' => false]);
|
||||||
|
$event = $this->makeEvent(['cost_unit_id' => $costUnit->id]);
|
||||||
|
|
||||||
|
$this->runRefund($this->makeParticipant($event));
|
||||||
|
|
||||||
|
$this->assertSame($costUnit->id, Invoice::first()->cost_unit_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Der reguläre Weg läuft mit
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_the_standard_confirmation_mail_is_sent(): void
|
||||||
|
{
|
||||||
|
$this->runRefund();
|
||||||
|
|
||||||
|
// Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg.
|
||||||
|
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_amount_paid_is_cleared_once_the_invoice_exists(): void
|
||||||
|
{
|
||||||
|
$participant = $this->makeParticipant($this->makeEvent());
|
||||||
|
$this->runRefund($participant);
|
||||||
|
|
||||||
|
// Mit der eingereichten Abrechnung ist der Beitrag auf dem Weg zurück und darf in den
|
||||||
|
// Zahlungsübersichten nicht länger als eingegangen stehen.
|
||||||
|
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_receipt_is_written_before_the_amount_is_cleared(): void
|
||||||
|
{
|
||||||
|
$refund = $this->runRefund();
|
||||||
|
|
||||||
|
// Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh genullt,
|
||||||
|
// stünde dort 0,00 €. Der Beleg selbst liegt als PDF vor; nachprüfbar ist die Reihenfolge an der
|
||||||
|
// Anmerkung, die im selben Schritt und aus derselben Quelle entsteht.
|
||||||
|
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
|
||||||
|
$this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user