6 Commits
Author SHA1 Message Date
th.guenther 40d5634764 Zahlungszwecke 2026-09-07 14:17:50 +02:00
th.guenther 350c8dd0d0 better handling payment purpose 2026-09-07 12:10:12 +02:00
th.guenther d07980dd1f Overview for refunds 2026-09-07 10:45:33 +02:00
th.guenther b9795f08f0 Besseres Handling Rückerstattungen 2026-09-06 20:11:50 +02:00
th.guenther e730d6db63 Korrektur Buchungstexte bei Auslagenerstattungen 2026-09-06 16:48:33 +02:00
th.guenther a6bddf9fa5 Merge pull request 'Dev 4.8.0' (#15) from dev-4.8.0 into main
Teili-Rechnungen
Teili-Rückerstattungen
EüR
2026-09-04 09:29:56 +02:00
60 changed files with 2381 additions and 297 deletions
@@ -93,7 +93,7 @@ class ExportController extends CommonController {
'amount' => $invoice->amount, 'amount' => $invoice->amount,
'recipient_name' => $invoice->contact_bank_owner, 'recipient_name' => $invoice->contact_bank_owner,
'recipient_iban' => $invoice->contact_bank_iban, 'recipient_iban' => $invoice->contact_bank_iban,
'payment_purpose' => $invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $invoice->invoice_number, 'payment_purpose' => $invoice->paymentPurposeText(),
]); ]);
} }
} }
@@ -89,12 +89,13 @@
<template> <template>
<table v-if="localData.invoices.length > 0" class="invoice-list-table"> <table v-if="localData.invoices.length > 0" class="invoice-list-table">
<tr> <tr>
<td colspan="6">{{props.data.costUnit.name}}</td> <td colspan="7">{{props.data.costUnit.name}}</td>
</tr> </tr>
<tr v-for="invoice in localData.invoices" :id="'invoice_' + invoice.id"> <tr v-for="invoice in localData.invoices" :id="'invoice_' + invoice.id">
<td>{{invoice.invoiceNumber}}</td> <td>{{invoice.invoiceNumber}}</td>
<td>{{invoice.invoiceType}}</td> <td>{{invoice.invoiceTypeShort}}</td>
<td style="max-width: 250px;">{{invoice.purpose}}</td>
<td> <td>
{{invoice.amount}} {{invoice.amount}}
</td> </td>
@@ -114,7 +115,7 @@
</tr> </tr>
<tr v-if="props.data.endpoint === 'approved'"> <tr v-if="props.data.endpoint === 'approved'">
<td colspan="5"></td> <td colspan="6"></td>
<td> <td>
<a style="font-size: 10pt;" class="link" @click="exportPayouts()">Genehmigte Abrechnungen exportieren</a> <a style="font-size: 10pt;" class="link" @click="exportPayouts()">Genehmigte Abrechnungen exportieren</a>
</td> </td>
@@ -148,7 +148,9 @@ class CreateIncomeSurplusStatementCommand
$rows[] = [ $rows[] = [
'number' => (string) $invoice->invoice_number, 'number' => (string) $invoice->invoice_number,
'date' => $invoice->created_at?->format('d.m.Y') ?? '', 'date' => $invoice->created_at?->format('d.m.Y') ?? '',
'purpose' => $this->purpose($invoice->type_other, $invoice->comment), // Ohne die Anmerkung: dort steht, was die Kassenwart*in beim Korrigieren notiert hat,
// und das gehört auf den Beleg, nicht in den Zweck.
'purpose' => $invoice->purposeText(),
'amount' => Amount::fromString($invoice->amount), 'amount' => Amount::fromString($invoice->amount),
]; ];
} }
@@ -165,27 +167,6 @@ class CreateIncomeSurplusStatementCommand
return ['groups' => $groups, 'total' => $total]; return ['groups' => $groups, 'total' => $total];
} }
/**
* Wofür der Beleg steht.
*
* `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
* nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
* Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
* Belegliste nur Platz kosten.
*/
private function purpose(?string $typeOther, ?string $comment): string
{
$parts = [];
foreach ([$typeOther, $comment] as $part) {
if (trim((string) $part) !== '') {
$parts[] = trim((string) $part);
}
}
return implode(' — ', $parts);
}
/** /**
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen. * Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
* *
@@ -386,7 +386,9 @@ function saveParticipant() {
Bankverbindung des Teilis Bankverbindung des Teilis
</small> </small>
<small v-else-if="props.participant.refund.status === 'accepted'"> <small v-else-if="props.participant.refund.status === 'accepted'">
bestätigt am {{ props.participant.refund.acceptedAt }} bestätigt am {{ props.participant.refund.acceptedAt }}<template
v-if="props.participant.refund.donation"
> &ndash; gespendet, keine Auszahlung</template>
</small> </small>
<!-- Was beim Verband geblieben ist und warum. --> <!-- Was beim Verband geblieben ist und warum. -->
@@ -60,6 +60,7 @@ const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOw
const refundReasons = ref([]); const refundReasons = ref([]);
const retentionReasons = ref([]); const retentionReasons = ref([]);
const refundSaving = ref(false); const refundSaving = ref(false);
const refundResending = ref(false);
const selectedRefundReason = computed( const selectedRefundReason = computed(
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null () => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
@@ -458,6 +459,8 @@ async function execRefund() {
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link. // Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '', accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '', accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
// Spende: kein Konto, trotzdem sofort eingereicht.
donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen. // Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
retentionReason: hasRetention.value ? refundForm.retentionReason : '', retentionReason: hasRetention.value ? refundForm.retentionReason : '',
retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '', retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '',
@@ -496,6 +499,32 @@ async function execCancelRefund(participant) {
} }
} }
/**
* Die Mail zur freigegebenen Erstattung noch einmal schicken -- ohne Rückfrage, es ändert sich nichts am
* Vorgang. Der Guard verhindert, dass ein zweiter Klick eine zweite Mail auslöst, bevor die erste durch ist.
*/
async function execResendRefundMail(participant) {
if (refundResending.value) {
return;
}
refundResending.value = true;
try {
const data = await request('/api/v1/participant-refund/' + participant.refund.token + '/resend-mail', {
method: "POST",
});
if (data?.status === 'success') {
toast.success(data.message);
} else {
toast.error(data?.message ?? 'Die Rückerstattungsmail konnte nicht versendet werden.');
}
} finally {
refundResending.value = false;
}
}
async function downloadRefundDocument(participant) { async function downloadRefundDocument(participant) {
const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document'); const ok = await download('/api/v1/participant-refund/' + participant.refund.token + '/document');
@@ -627,8 +656,9 @@ function mailToGroup(groupKey) {
> | Beitrag erstatten</span> > | Beitrag erstatten</span>
<template v-else-if="participant.refund?.status === 'pending'"> <template v-else-if="participant.refund?.status === 'pending'">
| <strong>Erstattung offen:</strong> {{ participant.refund.amount }}, | <strong>Rückerstattung vorgemerkt:</strong> {{ participant.refund.amount }}
wartet auf Bankverbindung am {{ participant.refund.releasedAt }}, wartet auf Bankverbindung
<span class="link" @click="execResendRefundMail(participant)">Rückerstattungsmail erneut senden</span>
<span class="link" style="color: #da7070;" @click="execCancelRefund(participant)">Abbrechen</span> <span class="link" style="color: #da7070;" @click="execCancelRefund(participant)">Abbrechen</span>
</template> </template>
@@ -772,8 +802,8 @@ function mailToGroup(groupKey) {
</template> </template>
<!-- <!--
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird Liegt die Bankverbindung schon vor oder will der Teili spenden, entfällt der Umweg über ihn:
sofort eingereicht. Er bekommt den Beleg trotzdem. die Erstattung wird sofort eingereicht. Er bekommt den Beleg trotzdem.
--> -->
<div class="refund-field"> <div class="refund-field">
<label class="refund-choice"> <label class="refund-choice">
@@ -784,8 +814,17 @@ function mailToGroup(groupKey) {
<input type="radio" value="management" v-model="refundForm.captureMode" /> <input type="radio" value="management" v-model="refundForm.captureMode" />
Bankverbindung liegt mir vor Bankverbindung liegt mir vor
</label> </label>
<label class="refund-choice">
<input type="radio" value="donation" v-model="refundForm.captureMode" />
Teilnehmer*in spendet den Betrag
</label>
</div> </div>
<p v-if="refundForm.captureMode === 'donation'" class="refund-hint">
Der Betrag wird nicht ausgezahlt, sondern als Spende gebucht. Die Erstattung wird sofort
eingereicht; der Teili erhält den Beleg per E-Mail.
</p>
<template v-if="refundForm.captureMode === 'management'"> <template v-if="refundForm.captureMode === 'management'">
<div class="refund-field"> <div class="refund-field">
<label for="refund_account_owner">Kontoinhaber*in</label> <label for="refund_account_owner">Kontoinhaber*in</label>
@@ -819,6 +858,7 @@ function mailToGroup(groupKey) {
> >
<template v-if="refundSaving">Wird gespeichert</template> <template v-if="refundSaving">Wird gespeichert</template>
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template> <template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
<template v-else-if="refundForm.captureMode === 'donation'">Als Spende einreichen</template>
<template v-else>Erstattung freigeben</template> <template v-else>Erstattung freigeben</template>
</button> </button>
</Modal> </Modal>
@@ -3,6 +3,8 @@
namespace App\Domains\Invoice\Actions\CreateInvoice; namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\TravelReason;
use App\Mail\InvoiceMails\InvoiceMailsNewInvoiceMail; use App\Mail\InvoiceMails\InvoiceMailsNewInvoiceMail;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail; use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail; use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
@@ -19,10 +21,19 @@ class CreateInvoiceCommand {
public function execute() : CreateInvoiceResponse { public function execute() : CreateInvoiceResponse {
$response = new CreateInvoiceResponse(); $response = new CreateInvoiceResponse();
$rejection = $this->rejectTravelReason();
if ($rejection !== null) {
$response->message = $rejection;
return $response;
}
if ($this->request->accountIban === 'undefined') { if ($this->request->accountIban === 'undefined') {
$this->request->accountIban = null; $this->request->accountIban = null;
} }
$travelReason = $this->travelReason();
$invoice = Invoice::create([ $invoice = Invoice::create([
'tenant' => currentTenant()->slug, 'tenant' => currentTenant()->slug,
'cost_unit_id' => $this->request->costUnit->id, 'cost_unit_id' => $this->request->costUnit->id,
@@ -30,6 +41,7 @@ class CreateInvoiceCommand {
'status' => InvoiceStatus::INVOICE_STATUS_NEW, 'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => $this->request->invoiceType, 'type' => $this->request->invoiceType,
'type_other' => $this->request->invoiceTypeExtended, 'type_other' => $this->request->invoiceTypeExtended,
'purpose' => $this->purpose($travelReason),
'donation' => $this->request->isDonation, 'donation' => $this->request->isDonation,
'user_id' => $this->request->paymentPurpose === null ? $this->request->userId : null, 'user_id' => $this->request->paymentPurpose === null ? $this->request->userId : null,
'contact_name' => $this->request->contactName, 'contact_name' => $this->request->contactName,
@@ -40,9 +52,7 @@ class CreateInvoiceCommand {
'amount' => $this->request->totalAmount, 'amount' => $this->request->totalAmount,
'distance' => $this->request->distance, 'distance' => $this->request->distance,
'travel_direction' => $this->request->travelRoute, 'travel_direction' => $this->request->travelRoute,
'travel_reason' => $this->request->travelReason, 'travel_reason' => $travelReason,
'passengers' => $this->request->passengers,
'transportation' => $this->request->transportations,
'payment_purpose' => $this->request->paymentPurpose, 'payment_purpose' => $this->request->paymentPurpose,
'comment' => $this->request->notices, 'comment' => $this->request->notices,
'document_filename' => $this->request->receiptFile !== null ? $this->request->receiptFile->fullPath : null, 'document_filename' => $this->request->receiptFile !== null ? $this->request->receiptFile->fullPath : null,
@@ -81,6 +91,68 @@ class CreateInvoiceCommand {
} }
/**
* Der Zahlungsgrund, einmal beim Anlegen festgehalten.
*
* Danach ist er eine eigene Angabe: Die Kassenwart*in kann ihn korrigieren, und nichts schreibt ihn
* mehr um. Bei Fahrtkosten setzt er sich aus Reisegrund und den gefahrenen Personen zusammen, sonst
* trägt ihn "Was wurde eingekauft". Wo nichts erfasst wird -- Beitragserstattungen -- bleibt er leer.
*
* Steht er schon fest, wird er übernommen: Eine Abrechnungskorrektur kopiert den Beleg, und ein von
* Hand gesetzter Grund darf dabei nicht verloren gehen.
*/
private function purpose(?string $travelReason) : ?string {
if (trim((string) $this->request->purpose) !== '') {
return $this->request->purpose;
}
$purpose = Invoice::joinPurposeParts(
$this->request->invoiceType === InvoiceType::INVOICE_TYPE_TRAVELLING
// Der Name des Grundes, nicht sein Schlüssel: In der Belegliste soll "Materialtransport"
// stehen, nicht "material_transport".
? [TravelReason::text($travelReason), $this->request->travellers]
: [$this->request->invoiceTypeExtended]
);
return $purpose === '' ? null : $purpose;
}
/**
* Was in `travel_reason` landet: der Schlüssel des gewählten Grundes -- oder, bei "Anderer Grund",
* der Text selbst. Der Schlüssel `other` sagt für sich nichts aus, der Text alles.
*
* Ein unbekannter Wert ist deshalb kein Fehler, sondern genau dieser Fall: So kommen auch
* Bestandsbelege und Kopien durch, die ihren Freitext schon mitbringen.
*/
private function travelReason() : ?string {
$reason = TravelReason::find($this->request->travelReason);
if ($reason === null) {
return $this->request->travelReason;
}
return $reason->requires_note
? trim((string) $this->request->travelReasonNote)
: $reason->slug;
}
/**
* Sicherheitsnetz hinter der Oberfläche: Dort geht es erst weiter, wenn die Erläuterung steht. Über
* einen direkten Aufruf ginge das sonst vorbei, und ein "Anderer Grund" ohne Text sagt nichts aus --
* gespeichert würde ein leerer Reisegrund.
*/
private function rejectTravelReason() : ?string {
$reason = TravelReason::find($this->request->travelReason);
if ($reason === null || !$reason->requires_note) {
return null;
}
return trim((string) $this->request->travelReasonNote) === ''
? 'Bitte gib an, was der Grund für die Reise war.'
: null;
}
private function generateInvoiceNumber() : string { private function generateInvoiceNumber() : string {
$lastInvoiceNumber = Invoice::query() $lastInvoiceNumber = Invoice::query()
->where('tenant', currentTenant()->slug) ->where('tenant', currentTenant()->slug)
@@ -16,16 +16,24 @@ class CreateInvoiceRequest {
public ?string $invoiceTypeExtended; public ?string $invoiceTypeExtended;
public ?string $travelRoute; public ?string $travelRoute;
public ?int $distance; public ?int $distance;
public ?int $passengers;
public ?int $transportations;
public ?InvoiceFile $receiptFile; public ?InvoiceFile $receiptFile;
public float $totalAmount; public float $totalAmount;
public bool $isDonation; public bool $isDonation;
public ?int $userId; public ?int $userId;
/** Der Slug eines Reisegrundes -- oder ein Freitext, wenn er von einem Bestandsbeleg stammt. */
public ?string $travelReason; public ?string $travelReason;
/** Die Erläuterung zu "Anderer Grund"; nur bei einem Grund mit `requires_note` von Belang. */
public ?string $travelReasonNote;
public ?string $paymentPurpose; public ?string $paymentPurpose;
public ?string $notices; public ?string $notices;
/** Wer gereist ist -- Freitext aus dem Fahrtkosten-Formular, geht in den Zahlungsgrund ein. */
public ?string $travellers;
/** Ein bereits feststehender Zahlungsgrund; gesetzt, gewinnt er über die Ermittlung im Command. */
public ?string $purpose;
public function __construct( public function __construct(
CostUnit $costUnit, CostUnit $costUnit,
@@ -42,11 +50,12 @@ class CreateInvoiceRequest {
?string $invoiceTypeExtended = null, ?string $invoiceTypeExtended = null,
?string $travelRoute = null, ?string $travelRoute = null,
?int $distance = null, ?int $distance = null,
?int $passengers = null,
?int $transportations,
?string $travelReason = null, ?string $travelReason = null,
?string $travelReasonNote = null,
?string $paymentPurpose = null, ?string $paymentPurpose = null,
?string $notices = null, ?string $notices = null,
?string $travellers = null,
?string $purpose = null,
) { ) {
$this->costUnit = $costUnit; $this->costUnit = $costUnit;
@@ -55,8 +64,6 @@ class CreateInvoiceRequest {
$this->invoiceTypeExtended = $invoiceTypeExtended; $this->invoiceTypeExtended = $invoiceTypeExtended;
$this->travelRoute = $travelRoute; $this->travelRoute = $travelRoute;
$this->distance = $distance; $this->distance = $distance;
$this->passengers = $passengers;
$this->transportations = $transportations;
$this->receiptFile = $receiptFile; $this->receiptFile = $receiptFile;
$this->contactEmail = $contactEmail; $this->contactEmail = $contactEmail;
$this->contactPhone = $contactPhone; $this->contactPhone = $contactPhone;
@@ -66,8 +73,11 @@ class CreateInvoiceRequest {
$this->isDonation = $isDonation; $this->isDonation = $isDonation;
$this->userId = $userId; $this->userId = $userId;
$this->travelReason = $travelReason; $this->travelReason = $travelReason;
$this->travelReasonNote = $travelReasonNote;
$this->paymentPurpose = $paymentPurpose; $this->paymentPurpose = $paymentPurpose;
$this->notices = $notices; $this->notices = $notices;
$this->travellers = $travellers;
$this->purpose = $purpose;
if ($accountIban === 'undefined') { if ($accountIban === 'undefined') {
$this->accountIban = null; $this->accountIban = null;
@@ -8,8 +8,12 @@ class CreateInvoiceResponse {
public bool $success; public bool $success;
public ?Invoice $invoice; public ?Invoice $invoice;
/** Warum keine Abrechnung entstanden ist -- für die Rückmeldung an die einreichende Person. */
public ?string $message;
public function __construct() { public function __construct() {
$this->success = false; $this->success = false;
$this->invoice = null; $this->invoice = null;
$this->message = null;
} }
} }
@@ -83,10 +83,8 @@ class CreateInvoiceReceiptCommand {
$travelPartTemplate = <<<HTML $travelPartTemplate = <<<HTML
<tr><td>Reiseweg:</td><td>%1\$s</td></tr> <tr><td>Reiseweg:</td><td>%1\$s</td></tr>
<tr><td>Grund der Reise:</td><td>%6\$s</td></tr> <tr><td>Grund der Reise:</td><td>%4\$s</td></tr>
<tr><td>Gesamtlänge der Strecke:</td><td>%2\$s km x %3\$s / km</td></tr> <tr><td>Gesamtlänge der Strecke:</td><td>%2\$s km x %3\$s / km</td></tr>
<tr><td>Materialtransport:</td><td>%4\$s</td></tr>
<tr><td>Mitfahrende im PKW:</td><td>%5\$s</td></tr>
HTML; HTML;
$flatTravelPart = sprintf( $flatTravelPart = sprintf(
@@ -94,8 +92,6 @@ HTML;
$invoiceReadable['travelDirection'] , $invoiceReadable['travelDirection'] ,
$invoiceReadable['distance'], $invoiceReadable['distance'],
$invoiceReadable['distanceAllowance'], $invoiceReadable['distanceAllowance'],
$invoiceReadable['transportation'],
$invoiceReadable['passengers'],
$invoiceReadable['travelReason'] , $invoiceReadable['travelReason'] ,
); );
@@ -180,7 +176,10 @@ HTML;
$invoiceReadable['contactEmail'], $invoiceReadable['contactEmail'],
$invoiceReadable['contactPhone'], $invoiceReadable['contactPhone'],
$invoiceReadable['costUnitName'], $invoiceReadable['costUnitName'],
$invoiceReadable['invoiceType'], // Der erfasste Zahlungsgrund, nicht der Abrechnungstyp: Der steht eine Zeile darüber schon
// als Überschrift. Bei Fahrtkosten ist das die einzige Stelle, an der auf dem Beleg steht,
// wer gereist ist.
$invoiceReadable['purpose'],
$invoiceReadable['donationText'], $invoiceReadable['donationText'],
$paymentType, $paymentType,
$invoiceReadable['amount'], $invoiceReadable['amount'],
@@ -34,6 +34,17 @@ class UpdateInvoiceCommand {
} }
$purpose = trim((string) $this->request->purpose);
$purpose = $purpose === '' ? null : $purpose;
// Verglichen wird gegen den angezeigten Text, nicht gegen die Spalte: Das Formular ist damit
// vorbelegt, und wer ihn unverändert abschickt, hat nichts geändert. Ein Bestandsbeleg behält so
// seine leere Spalte und damit die Ableitung; wer das Feld leert, schaltet zurück auf automatisch.
if (($purpose ?? '') !== $this->request->invoice->purposeText()) {
$changes .= 'Zahlungsgrund geändert von ' . $this->request->invoice->purposeText() . ' auf ' . ($purpose ?? '--') . '.<br />';
$this->request->invoice->purpose = $purpose;
}
$this->request->invoice->comment = $this->request->comment; $this->request->invoice->comment = $this->request->comment;
$this->request->invoice->changes = $changes; $this->request->invoice->changes = $changes;
@@ -13,9 +13,11 @@ class UpdateInvoiceRequest {
public CostUnit $costUnit; public CostUnit $costUnit;
public Invoice $invoice; public Invoice $invoice;
public Amount $amount; public Amount $amount;
public ?string $purpose;
public function __construct(Invoice $invoice, ?string $comment, InvoiceType $invoiceType, CostUnit $costUnit, Amount $amount) { public function __construct(Invoice $invoice, ?string $comment, InvoiceType $invoiceType, CostUnit $costUnit, Amount $amount, ?string $purpose = null) {
$this->comment = $comment; $this->comment = $comment;
$this->purpose = $purpose;
$this->invoiceType = $invoiceType; $this->invoiceType = $invoiceType;
$this->costUnit = $costUnit; $this->costUnit = $costUnit;
$this->invoice = $invoice; $this->invoice = $invoice;
@@ -32,25 +32,26 @@ class EditController extends CommonController{
$receiptfile->fullPath = $invoice->document_filename; $receiptfile->fullPath = $invoice->document_filename;
} }
$createInvoiceRequest = new CreateInvoiceRequest( $createInvoiceRequest = new CreateInvoiceRequest(
$invoice->costUnit()->first(), costUnit: $invoice->costUnit()->first(),
$invoice->contact_name, contactName: $invoice->contact_name,
$invoice->type, invoiceType: $invoice->type,
$invoice->amount, totalAmount: $invoice->amount,
$receiptfile, receiptFile: $receiptfile,
$invoice->donation, isDonation: $invoice->donation,
$invoice->user_id, userId: $invoice->user_id,
$invoice->contact_email, contactEmail: $invoice->contact_email,
$invoice->contact_phone, contactPhone: $invoice->contact_phone,
$invoice->contact_bank_owner, accountOwner: $invoice->contact_bank_owner,
$invoice->contact_bank_iban, accountIban: $invoice->contact_bank_iban,
$invoice->type_other, invoiceTypeExtended: $invoice->type_other,
$invoice->travel_direction, travelRoute: $invoice->travel_direction,
$invoice->distance, distance: $invoice->distance,
$invoice->passengers, travelReason: $invoice->travel_reason,
$invoice->transportation, paymentPurpose: $invoice->payment_purpose,
$invoice->travel_reason, notices: $invoice->comment,
$invoice->payment_purpose, // Die rohe Spalte, nicht purposeText(): Ein Beleg, der seinen Zahlungsgrund bisher ableitet,
$invoice->comment, // soll das als Kopie weiter tun.
purpose: $invoice->purpose,
); );
$invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest); $invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest);
@@ -92,7 +93,8 @@ class EditController extends CommonController{
$modifyData['notices'], $modifyData['notices'],
$invoiceType, $invoiceType,
$newCostUnit, $newCostUnit,
$newAmount $newAmount,
$modifyData['purpose'] ?? null
); );
$updateInvoiceCommand = new UpdateInvoiceCommand($updateInvoiceRequest); $updateInvoiceCommand = new UpdateInvoiceCommand($updateInvoiceRequest);
$updateInvoiceCommand->execute(); $updateInvoiceCommand->execute();
@@ -107,22 +109,22 @@ class EditController extends CommonController{
$receiptfile->fullPath = $invoice->document_filename; $receiptfile->fullPath = $invoice->document_filename;
} }
$createInvoiceRequest = new CreateInvoiceRequest( $createInvoiceRequest = new CreateInvoiceRequest(
$invoice->costUnit()->first(), costUnit: $invoice->costUnit()->first(),
$invoice->contact_name, contactName: $invoice->contact_name,
$invoice->type, invoiceType: $invoice->type,
$amountLeft->getAmount(), totalAmount: $amountLeft->getAmount(),
$receiptfile, receiptFile: $receiptfile,
$invoice->donation, isDonation: $invoice->donation,
$invoice->user_id, userId: $invoice->user_id,
$invoice->contact_email, contactEmail: $invoice->contact_email,
$invoice->contact_phone, contactPhone: $invoice->contact_phone,
$invoice->contact_bank_owner, accountOwner: $invoice->contact_bank_owner,
$invoice->contact_bank_iban, accountIban: $invoice->contact_bank_iban,
$invoice->type_other, invoiceTypeExtended: $invoice->type_other,
$invoice->travel_direction, travelRoute: $invoice->travel_direction,
$invoice->distance, distance: $invoice->distance,
$invoice->passengers, travelReason: $invoice->travel_reason,
$invoice->transportation purpose: $invoice->purpose,
); );
$invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest); $invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest);
@@ -66,50 +66,43 @@ class SaveInvoiceController extends CommonController
} }
$createInvoiceRequest = new CreateInvoiceRequest( $createInvoiceRequest = new CreateInvoiceRequest(
$costUnit, costUnit: $costUnit,
$request->input('name'), contactName: $request->input('name'),
InvoiceType::INVOICE_TYPE_TRAVELLING, invoiceType: InvoiceType::INVOICE_TYPE_TRAVELLING,
$amount, totalAmount: $amount,
$uploadedFile, receiptFile: $uploadedFile,
'donation' === $request->input('decision') ? true : false, isDonation: 'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'], userId: $this->users->getCurrentUserDetails()['userId'],
$request->input('email'), contactEmail: $request->input('email'),
$request->input('telephone'), contactPhone: $request->input('telephone'),
$request->input('accountOwner'), accountOwner: $request->input('accountOwner'),
$request->input('accountIban'), accountIban: $request->input('accountIban'),
null, travelRoute: $request->input('otherText'),
$request->input('otherText'), distance: $distance,
$distance, travelReason: $request->input('travelReason'),
$request->input('havePassengers'), travelReasonNote: $request->input('travelReasonNote'),
$request->input('materialTransportation'), notices: $notices,
$request->input('travelReason'), travellers: $request->input('travellers')
null,
$notices
); );
break; break;
default: default:
$createInvoiceRequest = new CreateInvoiceRequest( $createInvoiceRequest = new CreateInvoiceRequest(
$costUnit, costUnit: $costUnit,
$request->input('name'), contactName: $request->input('name'),
$invoiceType, invoiceType: $invoiceType,
Amount::fromString($request->input('amount'))->getAmount(), totalAmount: Amount::fromString($request->input('amount'))->getAmount(),
$uploadedFile, receiptFile: $uploadedFile,
'donation' === $request->input('decision') ? true : false, isDonation: 'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'], userId: $this->users->getCurrentUserDetails()['userId'],
$request->input('email'), contactEmail: $request->input('email'),
$request->input('telephone'), contactPhone: $request->input('telephone'),
$request->input('accountOwner'), accountOwner: $request->input('accountOwner'),
$request->input('accountIban'), accountIban: $request->input('accountIban'),
$request->input('otherText'), invoiceTypeExtended: $request->input('otherText'),
null, paymentPurpose: $paymentPurpose,
null, notices: $notices
$request->input('havePassengers'),
$request->input('materialTransportation'),
null,
$paymentPurpose,
$notices
); );
break; break;
@@ -128,5 +121,11 @@ class SaveInvoiceController extends CommonController
'message' => 'Alright' 'message' => 'Alright'
]); ]);
} }
return response()->json([
'status' => 'error',
'message' => $response->message
?? 'Beim Speichern ist ein Fehler aufgetreten. Bitte starte den Vorgang erneut.'
]);
} }
} }
@@ -37,15 +37,6 @@ const props = defineProps({
<td style="font-weight: bold">{{props.invoice.amount}}</td> <td style="font-weight: bold">{{props.invoice.amount}}</td>
</tr> </tr>
<tr>
<th>Marterialtransport</th>
<td>{{props.invoice.transportation}}</td>
</tr>
<tr>
<th>Hat Personen mitgenommen</th>
<td>{{props.invoice.passengers}}</td>
</tr>
</table> </table>
</template> </template>
@@ -16,11 +16,18 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel']) const emit = defineEmits(['submit', 'cancel'])
/**
* Die Anmerkung heißt in der Resource `comment` und ist dort `'--'`, wenn keine gesetzt ist -- beides
* muss hier stimmen, sonst startet das Feld leer und das Speichern löscht die vorhandene Anmerkung.
*/
const existingComment = props.newInvoice.comment
const formData = reactive({ const formData = reactive({
type_internal: props.newInvoice.internalType || '', type_internal: props.newInvoice.internalType || '',
cost_unit: props.newInvoice.costUnitId || '', cost_unit: props.newInvoice.costUnitId || '',
amount: props.newInvoice.amountPlain || '', amount: props.newInvoice.amountPlain || '',
notices: props.newInvoice.comments || '', purpose: props.newInvoice.purpose || '',
notices: !existingComment || existingComment === '--' ? '' : existingComment,
}) })
const submitForm = () => { const submitForm = () => {
@@ -70,6 +77,13 @@ onMounted(async () => {
</td> </td>
</tr> </tr>
<tr>
<td>Zahlungsgrund:</td>
<td>
<input type="text" v-model="formData.purpose" class="width-almost-full" />
</td>
</tr>
<tr> <tr>
<td>Anmerkungen:</td> <td>Anmerkungen:</td>
<td> <td>
@@ -100,6 +100,10 @@ const emit = defineEmits(["accept", "deny", "fix", "reopen"])
<td v-else-if="props.data.externalPayment">Rechnungszahlung</td> <td v-else-if="props.data.externalPayment">Rechnungszahlung</td>
<td v-else>Klassische Auszahlung</td> <td v-else>Klassische Auszahlung</td>
</tr> </tr>
<tr>
<td>Zahlungsgrund:</td>
<td colspan="3">{{props.data.purpose}}</td>
</tr>
<tr> <tr>
<td>Status:</td> <td>Status:</td>
<td>{{props.data.readableStatus}}</td> <td>{{props.data.readableStatus}}</td>
@@ -32,15 +32,6 @@ const props = defineProps({
<td style="font-weight: bold">{{props.invoice.amount}}</td> <td style="font-weight: bold">{{props.invoice.amount}}</td>
</tr> </tr>
<tr>
<th>Marterialtransport</th>
<td>{{props.invoice.transportation}}</td>
</tr>
<tr>
<th>Hat Personen mitgenommen</th>
<td>{{props.invoice.passengers}}</td>
</tr>
</table> </table>
</template> </template>
@@ -45,12 +45,13 @@
<template> <template>
<table v-if="localData.invoices.length > 0" class="invoice-list-table"> <table v-if="localData.invoices.length > 0" class="invoice-list-table">
<tr> <tr>
<td colspan="6">{{props.data.title}}</td> <td colspan="7">{{props.data.title}}</td>
</tr> </tr>
<tr v-for="invoice in localData.invoices" :id="'invoice_' + invoice.id"> <tr v-for="invoice in localData.invoices" :id="'invoice_' + invoice.id">
<td>{{invoice.invoiceNumber}}</td> <td>{{invoice.invoiceNumber}}</td>
<td>{{invoice.invoiceType}}</td> <td>{{invoice.invoiceTypeShort}}</td>
<td style="max-width: 250px;">{{invoice.purpose}}</td>
<td> <td>
{{invoice.amount}} {{invoice.amount}}
</td> </td>
@@ -24,8 +24,6 @@ const props = defineProps({
userTelephone: String, userTelephone: String,
userAccountOwner: String, userAccountOwner: String,
userAccountIban: String, userAccountIban: String,
havePassengers: Number,
materialTransportation: Boolean,
travelReason: String, travelReason: String,
}) })
@@ -61,8 +59,6 @@ async function sendData() {
formData.append('accountOwner', userAccountOwner.value) formData.append('accountOwner', userAccountOwner.value)
formData.append('accountIban', userIban.value) formData.append('accountIban', userIban.value)
formData.append('paymentPurpose', paymentPurpose.value) formData.append('paymentPurpose', paymentPurpose.value)
formData.append('havePassengers', props.havePassengers ? 1 : 0)
formData.append('materialTransportation', props.materialTransportation ? 1 : 0)
formData.append('travelReason', props.travelReason) formData.append('travelReason', props.travelReason)
if (props.receipt) { if (props.receipt) {
@@ -24,9 +24,9 @@ const props = defineProps({
userTelephone: String, userTelephone: String,
userAccountOwner: String, userAccountOwner: String,
userAccountIban: String, userAccountIban: String,
havePassengers: Number,
materialTransportation: Boolean,
travelReason: String, travelReason: String,
travelReasonNote: String,
travellers: String,
}) })
const finalStep = ref(true) const finalStep = ref(true)
@@ -60,9 +60,9 @@ async function sendData() {
formData.append('decision', decision.value) formData.append('decision', decision.value)
formData.append('accountOwner', userAccountOwner.value) formData.append('accountOwner', userAccountOwner.value)
formData.append('accountIban', userIban.value) formData.append('accountIban', userIban.value)
formData.append('havePassengers', props.havePassengers ? 1 : 0)
formData.append('materialTransportation', props.materialTransportation ? 1 : 0)
formData.append('travelReason', props.travelReason) formData.append('travelReason', props.travelReason)
formData.append('travelReasonNote', props.travelReasonNote ?? '')
formData.append('travellers', props.travellers ?? '')
formData.append('notices', notices.value) formData.append('notices', notices.value)
if (props.receipt) { if (props.receipt) {
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, reactive } from 'vue' import { ref, computed, onMounted, reactive } from 'vue'
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js"; import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
import RefundData from "./refund-data.vue"; import RefundData from "./refund-data.vue";
import NumericInput from "../../../../../Views/Components/NumericInput.vue"; import NumericInput from "../../../../../Views/Components/NumericInput.vue";
@@ -19,15 +19,44 @@ const { request } = useAjax();
const distanceAllowance = ref(null); const distanceAllowance = ref(null);
const travelDirection = ref(null); const travelDirection = ref(null);
const travelReason = ref(null); const travelReason = ref(null);
const travelReasonNote = ref('');
const travelReasonCollection = reactive({
travelReasons: []
});
const selectedTravelReason = computed(() =>
travelReasonCollection.travelReasons.find((reason) => reason.value === travelReason.value)
);
/**
* Ein Grund ist erst vollständig, wenn die Erläuterung steht, die er verlangt: "Anderer Grund" allein
* sagt nichts aus. Die drei anderen Gründe bremsen den Ablauf dadurch nicht.
*/
const travelReasonComplete = computed(() =>
selectedTravelReason.value !== undefined
&& (!selectedTravelReason.value.requiresNote || travelReasonNote.value.trim() !== '')
);
/**
* Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer
* den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login
* bleibt das Feld leer, und es hält den Ablauf auch dann nicht auf: Ein Name ist eine Hilfe, keine
* Bedingung.
*/
const travellers = ref(data.userName || '');
const have_receipt = ref('') const have_receipt = ref('')
const havePassengers = ref(false);
const materialTransportation = ref(false);
const amount = ref(0.00); const amount = ref(0.00);
const invoiceType = ref(null); const invoiceType = ref(null);
const otherText = ref(''); const otherText = ref('');
const receipt = ref(null) const receipt = ref(null)
const finalStep = ref(false) const finalStep = ref(false)
onMounted(async () => {
const response = await fetch('/api/v1/core/retrieve-travel-reasons');
travelReasonCollection.travelReasons = await response.json();
});
async function getDistanceAllowance() { async function getDistanceAllowance() {
const tempData = await request('/api/v1/cost-unit/get-distance-allowance/' + data.eventId, { const tempData = await request('/api/v1/cost-unit/get-distance-allowance/' + data.eventId, {
method: "GET", method: "GET",
@@ -59,14 +88,40 @@ function handleFileChange(event) {
<fieldset v-if="travelDirection !== null"> <fieldset v-if="travelDirection !== null">
<legend><span style="font-weight: bolder;">Was war der Grund für deine Reise?</span></legend> <legend><span style="font-weight: bolder;">Was war der Grund für deine Reise?</span></legend>
<p v-for="availableReason in travelReasonCollection.travelReasons">
<input
name="travel-reason"
type="radio"
:value="availableReason.value"
:id="'travel_reason_' + availableReason.value"
v-model="travelReason"
>
<label :for="'travel_reason_' + availableReason.value">{{ availableReason.label }}</label><br />
</p>
<template v-if="selectedTravelReason?.requiresNote">
<input <input
type="text" type="text"
name="travel-reason" class="width-full"
v-model="travelReason" name="travel-reason-note"
placeholder="z. B. Abholung der Ausrüstung aus dem Lager"
v-model="travelReasonNote"
/>
</template>
</fieldset><br /><br />
<fieldset v-if="travelReasonComplete">
<legend><span style="font-weight: bolder;">Welche Personen sind gereist?</span></legend>
<input
type="text"
name="travellers"
placeholder="z. B. Mika, Kim und Alex"
v-model="travellers"
/> />
</fieldset><br /><br /> </fieldset><br /><br />
<fieldset v-if="travelReason !== null"> <fieldset v-if="travelReasonComplete">
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend> <legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" /> <input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" /> <input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
@@ -93,8 +148,6 @@ function handleFileChange(event) {
invoice-type="travelling" invoice-type="travelling"
:amount="amount" :amount="amount"
:other-text="travelDirection" :other-text="travelDirection"
:materialTransportation="materialTransportation"
:havePassengers="havePassengers"
:userName="data.userName" :userName="data.userName"
:userEmail="data.userEmail" :userEmail="data.userEmail"
:userTelephone="data.userTelephone" :userTelephone="data.userTelephone"
@@ -102,6 +155,8 @@ function handleFileChange(event) {
:userAccountOwner="data.userAccountOwner" :userAccountOwner="data.userAccountOwner"
:receipt="receipt" :receipt="receipt"
:travelReason="travelReason" :travelReason="travelReason"
:travelReasonNote="travelReasonNote"
:travellers="travellers"
@close="finalStep = false" @close="finalStep = false"
/> />
</fieldset> </fieldset>
@@ -117,22 +172,6 @@ function handleFileChange(event) {
<span style="font-weight: normal">({{ amount }} km x {{distanceAllowance.toFixed(2).replace('.', ',')}} Euro / km = <strong>{{ (amount * distanceAllowance).toFixed(2).replace('.', ',') }} Euro</strong>)</span> <span style="font-weight: normal">({{ amount }} km x {{distanceAllowance.toFixed(2).replace('.', ',')}} Euro / km = <strong>{{ (amount * distanceAllowance).toFixed(2).replace('.', ',') }} Euro</strong>)</span>
<br /><br /> <br /><br />
<input
type="checkbox"
name="havePassengers"
v-model="havePassengers"
id="havePassengers"
/> <label style="margin-bottom: 20px;" for="havePassengers">Ich habe Personen mitgenommen</label>
<br />
<input
type="checkbox"
name="materialTransportation"
v-model="materialTransportation"
id="materialTransportation"
/> <label style="margin-bottom: 20px;" for="materialTransportation">Ich habe Material transportiert</label>
<br /><br />
<input <input
v-if="amount !== null && have_receipt === 'no' && amount != '0'" v-if="amount !== null && have_receipt === 'no' && amount != '0'"
@click="finalStep = true;" @click="finalStep = true;"
@@ -145,14 +184,14 @@ function handleFileChange(event) {
invoice-type="travelling" invoice-type="travelling"
:amount="amount" :amount="amount"
:other-text="travelDirection" :other-text="travelDirection"
:materialTransportation="materialTransportation"
:havePassengers="havePassengers"
:userName="data.userName" :userName="data.userName"
:userEmail="data.userEmail" :userEmail="data.userEmail"
:userTelephone="data.userTelephone" :userTelephone="data.userTelephone"
:userAccountIban="data.userAccountIban" :userAccountIban="data.userAccountIban"
:userAccountOwner="data.userAccountOwner" :userAccountOwner="data.userAccountOwner"
:travelReason="travelReason" :travelReason="travelReason"
:travelReasonNote="travelReasonNote"
:travellers="travellers"
@close="finalStep = false" @close="finalStep = false"
/> />
@@ -74,6 +74,15 @@ class AcceptRefundCommand
$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.';
} }
// Wer spendet, gibt kein Konto an -- alles Weitere betrifft nur die Auszahlung.
if (!$this->request->donation) {
// Erstattet wird ausschließlich auf das Konto, von dem der Beitrag kam. Ohne diese
// Bestätigung ließe sich über eine Erstattung Geld auf ein fremdes Konto umleiten.
if (!$this->request->accountDeclarationAccepted && $this->request->capturedBy === null) {
$response->errorTypes['accountDeclaration'] = 'Bitte bestätige, dass es das Konto ist, '
. 'von dem der Beitrag gezahlt wurde.';
}
if ($owner === '') { if ($owner === '') {
$response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.'; $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
} }
@@ -83,6 +92,7 @@ class AcceptRefundCommand
} elseif (!Iban::isValid($iban)) { } elseif (!Iban::isValid($iban)) {
$response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.'; $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
} }
}
if ($response->errorTypes !== []) { if ($response->errorTypes !== []) {
$response->message = 'Bitte prüfe deine Angaben.'; $response->message = 'Bitte prüfe deine Angaben.';
@@ -108,14 +118,21 @@ class AcceptRefundCommand
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das // Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten. // Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) { $document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
$refund->account_owner = $owner; // Bei einer Spende bleiben die Kontofelder leer -- und zwar `null` und nicht Leerstring: Der
$refund->account_iban = $iban; // SEPA-Export unterscheidet daran, ob es etwas auszuzahlen gibt.
$refund->account_owner = $this->request->donation ? null : $owner;
$refund->account_iban = $this->request->donation ? null : $iban;
$refund->captured_by = $this->request->capturedBy; $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();
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); // Die Spende wird hier ausdrücklich mitgegeben statt am Vorgang abgelesen: Die Abrechnung,
// die sie führt, entsteht erst weiter unten -- dieser Beleg ist ihr Anhang.
$document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest(
refund: $refund,
donation: $this->request->donation,
))->execute();
$invoice = $this->createInvoice($refund, $costUnit, $document); $invoice = $this->createInvoice($refund, $costUnit, $document);
@@ -132,7 +149,9 @@ class AcceptRefundCommand
$this->notify($refund, $document); $this->notify($refund, $document);
$response->success = true; $response->success = true;
$response->message = 'Vielen Dank. Deine Angaben liegen uns vor.'; $response->message = $this->request->donation
? 'Vielen Dank für deine Spende.'
: 'Vielen Dank. Deine Angaben liegen uns vor.';
return $response; return $response;
} }
@@ -177,7 +196,9 @@ class AcceptRefundCommand
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0, totalAmount: $refund->amount?->getAmount() ?? 0.0,
receiptFile: $this->storeReceipt($costUnit, $document), receiptFile: $this->storeReceipt($costUnit, $document),
isDonation: false, // Hier landet die Entscheidung des Teilis, und nur hier: Die Abrechnung führt sie, der
// Vorgang liest sie über ParticipantRefund::isDonation() zurück.
isDonation: $this->request->donation,
userId: $participant->user_id, userId: $participant->user_id,
contactEmail: $participant->email_1, contactEmail: $participant->email_1,
contactPhone: $participant->phone_1, contactPhone: $participant->phone_1,
@@ -186,15 +207,6 @@ class AcceptRefundCommand
accountOwner: $refund->account_owner, accountOwner: $refund->account_owner,
accountIban: $refund->account_iban, 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 // MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas
// steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen". // steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen".
paymentPurpose: null, paymentPurpose: null,
@@ -247,8 +259,15 @@ class AcceptRefundCommand
{ {
$paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro'; $paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro';
// Die Schatzmeisterei sieht die Abrechnung ohne den Vorgang dahinter. Dass nichts ausgezahlt
// wird, steht zwar im Spendenkennzeichen -- warum keine Bankverbindung dabei ist, aber nur hier.
$subject = $this->request->donation
? 'Spende statt Rückerstattung Teilnahmebeitrag'
: 'Rückerstattung Teilnahmebeitrag';
return Str::limit(sprintf( return Str::limit(sprintf(
'Rückerstattung Teilnahmebeitrag %s %s', '%s %s %s',
$subject,
$refund->event->name, $refund->event->name,
$refund->reasonLabel() $refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid); ), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
@@ -10,8 +10,28 @@ class AcceptRefundRequest
public readonly ?ParticipantRefund $refund, public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner, public readonly string $accountOwner,
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.
*
* Im Spendenweg ist es die Verzichtserklärung, im Auszahlungsweg die Versicherung über den
* gezahlten Beitrag -- in beiden Fällen die Erklärung, die anschließend auf dem Beleg steht.
*/
public readonly bool $declarationAccepted = false, public readonly bool $declarationAccepted = false,
/**
* Ob auf die Auszahlung verzichtet und der Betrag gespendet wird.
*
* Wird nicht am Vorgang gespeichert: Die Entscheidung landet in der Abrechnung
* (`invoices.donation`), von wo {@see ParticipantRefund::isDonation()} sie zurückliest. Dieses
* Feld ist der Weg dorthin.
*/
public readonly bool $donation = false,
/**
* Nur im Auszahlungsweg: die Bestätigung, dass es das Konto der Ursprungszahlung ist.
*
* Getrennt von der Haupterklärung, weil sie im Spendenweg gegenstandslos ist -- dort gibt es
* kein Konto.
*/
public readonly bool $accountDeclarationAccepted = false,
/** /**
* Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag. * Die Aktionsleitung, wenn sie die Bankverbindung aufgenommen hat, weil sie ihr vorlag.
* *
@@ -27,6 +27,12 @@ class CreateRefundDocumentCommand
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */ /** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND'; public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
private ParticipantRefund $refund; private ParticipantRefund $refund;
private EventParticipant $participant; private EventParticipant $participant;
@@ -148,12 +154,54 @@ class CreateRefundDocumentCommand
private function declarationText(): string private function declarationText(): string
{ {
$text = PageText::where('name', self::DECLARATION_TEXT)->first()?->content; if ($this->request->donation) {
return $this->pageText(
self::DONATION_DECLARATION_TEXT,
'Ich verzichte auf die Auszahlung des genannten Betrags und spende ihn an den Verband. '
. 'Mir ist bewusst, dass dieser Verzicht nicht rückgängig gemacht werden kann.'
);
}
return trim((string) $text) !== '' // Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
? (string) $text // gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
: 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig ' return $this->pageText(
. 'zurückerstattet bekomme.'; self::DECLARATION_TEXT,
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
. 'zurückerstattet bekomme.'
) . '<br /><br />' . $this->pageText(
self::ACCOUNT_DECLARATION_TEXT,
'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
. 'gezahlt wurde.'
);
}
/**
* Ein Seitentext mit Rückfallwert: Fehlt die Zeile in der Datenbank, soll der Beleg trotzdem
* entstehen. Ohne den Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im
* Deckblatt-Code der Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
*/
private function pageText(string $name, string $fallback): string
{
$text = PageText::where('name', $name)->first()?->content;
return trim((string) $text) !== '' ? (string) $text : $fallback;
}
/**
* Der Einleitungssatz des Belegs, passend zum gewählten Weg.
*
* Als Platzhalter und nicht fest in der Vorlage, weil er sich zwischen Auszahlung und Spende
* unterscheidet -- `{if:…}` kennt keine Verneinung, mit der eine Vorlage den einen Satz gegen den
* anderen tauschen könnte. Ältere, bereits installierte Vorlagen tragen den festen Satz weiter; dass
* gespendet wurde, steht dort in der Angabentabelle und in der Erklärung.
*/
private function introText(): string
{
return $this->request->donation
? 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen. Auf die Auszahlung '
. 'des erstattungsfähigen Betrags verzichte ich und spende ihn an den Verband:'
: 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die '
. 'Rückerstattung wie folgt:';
} }
/** /**
@@ -222,6 +270,7 @@ class CreateRefundDocumentCommand
'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0), 'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
'retention_note' => $this->retentionNote(), 'retention_note' => $this->retentionNote(),
'intro_text' => $this->introText(),
'declaration_text' => $this->declarationText(), 'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(), 'capture_note' => $this->captureNote(),
@@ -272,8 +321,17 @@ class CreateRefundDocumentCommand
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())]; $rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
} }
// Bei einer Spende gibt es keine Bankverbindung. Statt zwei leerer Zeilen steht dort, warum --
// der Beleg wandert in die Buchhaltung, und "keine IBAN" allein sähe nach einer Lücke aus.
if ($this->request->donation) {
$rows[] = [
'Auszahlung',
'Auf die Auszahlung wird verzichtet; der Betrag verbleibt als Spende beim Verband.',
];
} else {
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)]; $rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))]; $rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
}
$html = ''; $html = '';
foreach ($rows as [$key, $value]) { foreach ($rows as [$key, $value]) {
@@ -8,6 +8,14 @@ class CreateRefundDocumentRequest
{ {
public function __construct( public function __construct(
public readonly ParticipantRefund $refund, public readonly ParticipantRefund $refund,
/**
* Ob der Betrag gespendet statt ausgezahlt wird.
*
* Ausdrücklich und nicht über {@see ParticipantRefund::isDonation()}: Beim Einreichen entsteht
* dieser Beleg vor der Abrechnung, die die Spende führt -- er ist ihr Anhang. Wer ihn später
* erneut zieht, liest sie dort und gibt sie hier weiter.
*/
public readonly bool $donation = false,
) { ) {
} }
} }
@@ -67,22 +67,24 @@ class ReleaseRefundCommand
'released_at' => now(), 'released_at' => now(),
]); ]);
if ($this->request->hasBankDetails()) { if ($this->request->submitsDirectly()) {
$this->submitDirectly($refund); $this->submitDirectly($refund);
} }
return $refund; return $refund;
}); });
if (!$this->request->hasBankDetails()) { if (!$this->request->submitsDirectly()) {
$this->notify($refund); $this->notify($refund);
} }
$response->success = true; $response->success = true;
$response->refund = $refund->fresh(); $response->refund = $refund->fresh();
$response->message = $this->request->hasBankDetails() $response->message = match (true) {
? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.' $this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
: 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.'; $this->request->hasBankDetails() => 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
default => 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.',
};
return $response; return $response;
} }
@@ -99,6 +101,7 @@ class ReleaseRefundCommand
refund: $refund, refund: $refund,
accountOwner: (string) $this->request->accountOwner, accountOwner: (string) $this->request->accountOwner,
accountIban: (string) $this->request->accountIban, accountIban: (string) $this->request->accountIban,
donation: $this->request->donation,
// Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by` // Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by`
// fest, und der Beleg weist es aus. // fest, und der Beleg weist es aus.
capturedBy: currentUser()?->id, capturedBy: currentUser()?->id,
@@ -181,21 +184,29 @@ class ReleaseRefundCommand
*/ */
private function rejectBankDetails(): ?string private function rejectBankDetails(): ?string
{ {
// Eine Spende wird nicht ausgezahlt. Kämen beide Angaben zusammen, wäre unklar, was gilt --
// lieber nachfragen als das eine stillschweigend gegen das andere entscheiden.
if ($this->request->donation && (filled($this->request->accountOwner) || filled($this->request->accountIban))) {
return 'Eine Spende braucht keine Bankverbindung.';
}
// Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili. // Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili.
if (filled($this->request->accountOwner) !== filled($this->request->accountIban)) { if (!$this->request->donation
&& filled($this->request->accountOwner) !== filled($this->request->accountIban)) {
return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.'; return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.';
} }
if (!$this->request->hasBankDetails()) { if (!$this->request->submitsDirectly()) {
return null; return null;
} }
if (!Iban::isValid((string) $this->request->accountIban)) { if ($this->request->hasBankDetails() && !Iban::isValid((string) $this->request->accountIban)) {
return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.'; 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 // Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen -- auch die Spende braucht eine, sie
// Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht. // wird ja gebucht. Hier abfangen und nicht erst in der Transaktion, damit die Aktionsleitung eine
// verständliche Meldung sieht.
if ($this->participant->event->cost_unit_id === null) { if ($this->participant->event->cost_unit_id === null) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.'; return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
} }
@@ -28,6 +28,13 @@ class ReleaseRefundRequest
*/ */
public readonly ?string $retentionReason = null, public readonly ?string $retentionReason = null,
public readonly ?string $retentionReasonNote = null, public readonly ?string $retentionReasonNote = null,
/**
* Der Teili spendet den Betrag, statt ihn ausgezahlt zu bekommen.
*
* Dann braucht es keine Bankverbindung, und die Erstattung wird -- wie beim vorliegenden Konto --
* sofort eingereicht. Gespeichert wird die Entscheidung in der Abrechnung, nicht am Vorgang.
*/
public readonly bool $donation = false,
) { ) {
} }
@@ -37,6 +44,12 @@ class ReleaseRefundRequest
return filled($this->accountOwner) && filled($this->accountIban); return filled($this->accountOwner) && filled($this->accountIban);
} }
/** Ob sofort eingereicht wird -- entweder liegt die Bankverbindung vor, oder es wird gespendet. */
public function submitsDirectly(): bool
{
return $this->hasBankDetails() || $this->donation;
}
/** /**
* Der Betrag, der beim Verband bleibt. * Der Betrag, der beim Verband bleibt.
* *
@@ -0,0 +1,70 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\EventParticipant;
use Illuminate\Support\Facades\Mail;
/**
* Schickt die Mail zu einer freigegebenen Erstattung noch einmal.
*
* Der häufige Fall, wenn ein Vorgang hängt: die erste Mail ist untergegangen. Am Vorgang ändert sich
* dabei nichts -- `released_at` bleibt der Zeitpunkt der Vormerkung, der Token bleibt derselbe, der alte
* Link funktioniert also weiter.
*
* Nur solange die Bankverbindung fehlt: nach der Bestätigung wäre der Link wertlos, nach dem Abbruch
* liefe er ins Leere.
*/
class ResendRefundMailCommand
{
public function __construct(private readonly ResendRefundMailRequest $request)
{
}
public function execute(): ResendRefundMailResponse
{
$response = new ResendRefundMailResponse();
$refund = $this->request->refund;
if (!$refund->isPending()) {
$response->message = $refund->isAccepted()
? 'Diese Erstattung wurde bereits bestätigt -- es gibt nichts mehr nachzureichen.'
: 'Diese Erstattung wurde abgebrochen.';
return $response;
}
$this->notify();
$response->success = true;
$response->message = 'Die Rückerstattungsmail wurde erneut versendet.';
return $response;
}
/**
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Freigabe
* (siehe ReleaseRefundCommand).
*/
private function notify(): void
{
/** @var EventParticipant $participant */
$participant = $this->request->refund->participant()->first();
$recipients = [$participant->email_1];
// `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei
// 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 RefundReleasedMail(
participant: $participant,
refund: $this->request->refund,
));
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
use App\Models\ParticipantRefund;
class ResendRefundMailRequest
{
public function __construct(
public readonly ParticipantRefund $refund,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\ParticipantRefund\Actions\ResendRefundMail;
class ResendRefundMailResponse
{
public bool $success = false;
public ?string $message = null;
}
@@ -22,6 +22,8 @@ class AcceptRefundController extends CommonController
accountOwner: (string) $request->input('accountOwner'), accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'), accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'), declarationAccepted: $request->boolean('declarationAccepted'),
donation: $request->boolean('donation'),
accountDeclarationAccepted: $request->boolean('accountDeclarationAccepted'),
); );
$response = new AcceptRefundCommand($acceptRequest)->execute(); $response = new AcceptRefundCommand($acceptRequest)->execute();
@@ -18,7 +18,11 @@ class RefundDocumentController extends CommonController
abort(403, 'Zugriff verweigert.'); abort(403, 'Zugriff verweigert.');
} }
$documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); $documentResponse = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest(
refund: $refund,
// Beim Nachdruck steht die Abrechnung längst -- sie führt die Spende.
donation: $refund->isDonation(),
))->execute();
if (!$documentResponse->success) { if (!$documentResponse->success) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.'); abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
@@ -53,6 +53,8 @@ class RefundPageController extends CommonController
return array_merge($common, [ return array_merge($common, [
'state' => 'accepted', 'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'), 'acceptedAt' => $refund->accepted_at?->format('d.m.Y'),
// Wurde gespendet, darf der Abschlusstext keine Überweisung ankündigen, die nicht kommt.
'donation' => $refund->isDonation(),
]); ]);
} }
@@ -31,6 +31,9 @@ class ReleaseRefundController extends CommonController
// Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen. // Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
retentionReason: Text::nullIfBlank($request->input('retentionReason')), retentionReason: Text::nullIfBlank($request->input('retentionReason')),
retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')), retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')),
// Der Teili hat der Aktionsleitung gesagt, dass er spenden möchte -- dann entfällt die
// Bankverbindung und die Erstattung wird sofort als Spende eingereicht.
donation: $request->boolean('donation'),
); );
$response = new ReleaseRefundCommand($refundRequest)->execute(); $response = new ReleaseRefundCommand($refundRequest)->execute();
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\ParticipantRefund\Controllers;
use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailCommand;
use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ResendRefundMailController extends CommonController
{
public function __invoke(string $refundToken, Request $request): JsonResponse
{
$refund = $this->participantRefunds->getByToken($refundToken);
// Der Token ist hier keine Berechtigung: nachschicken darf nur, wer die Veranstaltung auch
// verwalten kann. `getById()` prüft genau das.
if ($refund === null || $this->events->getById($refund->event_id) === null) {
abort(403, 'Zugriff verweigert.');
}
$response = new ResendRefundMailCommand(new ResendRefundMailRequest($refund))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -67,7 +67,8 @@ final class ParticipantRefundTokens
'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'], 'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'],
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'], 'account_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ärungen, die die teilnehmende Person bestätigt hat (gepflegt als Seitentexte CONFIRMATION_PARTICIPANT_REFUND und CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT, bei einer Spende CONFIRMATION_PARTICIPANT_REFUND_DONATION)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.<br /><br />Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag gezahlt wurde.'],
'intro_text' => ['description' => 'Einleitungssatz des Belegs — bei einer Spende der Verzicht statt der Bitte um Rückerstattung', 'sample' => 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die Rückerstattung wie folgt:'],
'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'], 'capture_note' => ['description' => 'Vermerk, wenn die Aktionsleitung die Bankverbindung aufgenommen hat — sonst leer', 'sample' => 'Angaben aufgenommen durch Aktions Leitung am 18.06.2026.'],
], ],
], ],
@@ -4,6 +4,7 @@ use App\Domains\ParticipantRefund\Controllers\AcceptRefundController;
use App\Domains\ParticipantRefund\Controllers\CancelRefundController; use App\Domains\ParticipantRefund\Controllers\CancelRefundController;
use App\Domains\ParticipantRefund\Controllers\RefundDocumentController; use App\Domains\ParticipantRefund\Controllers\RefundDocumentController;
use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController; use App\Domains\ParticipantRefund\Controllers\ReleaseRefundController;
use App\Domains\ParticipantRefund\Controllers\ResendRefundMailController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -17,6 +18,7 @@ Route::prefix('api/v1')
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::post('{participantIdentifier}/release', ReleaseRefundController::class); Route::post('{participantIdentifier}/release', ReleaseRefundController::class);
Route::post('{refundToken}/cancel', CancelRefundController::class); Route::post('{refundToken}/cancel', CancelRefundController::class);
Route::post('{refundToken}/resend-mail', ResendRefundMailController::class);
Route::get('{refundToken}/document', RefundDocumentController::class); Route::get('{refundToken}/document', RefundDocumentController::class);
}); });
}); });
@@ -27,13 +27,33 @@ const props = defineProps({
reason: String, reason: String,
reasonNote: String, reasonNote: String,
acceptedAt: String, acceptedAt: String,
donation: Boolean,
}) })
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden. // Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state) const state = ref(props.state)
const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false}) // Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
const errors = reactive({accountOwner: '', accountIban: '', declaration: ''}) // Neuladen holt ihn vom Controller.
const donated = ref(props.donation === true)
/**
* Der Weg durch die Seite, nach dem Muster der Auslagenabrechnung (refund-data.vue): erst die
* Entscheidung, dann die Angaben dazu. Nichts wird gefragt, was für den gewählten Weg keine Rolle spielt.
*
* decision -- '' (noch nichts gewählt) | 'donation' (spenden) | 'payout' (auszahlen lassen)
* sameAccount -- nur bei 'payout': ob es das Konto der Ursprungszahlung ist
*/
const decision = ref('')
const sameAccount = ref(null)
const form = reactive({
accountOwner: '',
accountIban: '',
declarationAccepted: false,
accountDeclarationAccepted: false,
})
const errors = reactive({accountOwner: '', accountIban: '', declaration: '', accountDeclaration: ''})
const saving = ref(false) const saving = ref(false)
/** /**
@@ -48,12 +68,41 @@ const accountComplete = computed(
() => form.accountOwner.trim() !== '' && form.accountIban.trim() !== '' () => form.accountOwner.trim() !== '' && form.accountIban.trim() !== ''
) )
const isDonation = computed(() => decision.value === 'donation')
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
* Ohne das Zurücksetzen stünde eine Erklärung als bestätigt da, die in diesem Weg gar nicht gezeigt
* wurde -- wer erst die Auszahlung ankreuzt und dann zur Spende wechselt, hätte den Verzicht nie gelesen.
*/
function choose(next) {
decision.value = next
sameAccount.value = null
form.declarationAccepted = false
form.accountDeclarationAccepted = false
Object.keys(errors).forEach((key) => (errors[key] = ''))
}
/** Zurück zur ersten Frage -- aus der Sackgasse „anderes Konto" führt der Weg zur Spende. */
function reset() {
choose('')
}
function validate() { function validate() {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.' Object.keys(errors).forEach((key) => (errors[key] = ''))
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.' errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
return !errors.accountOwner && !errors.accountIban && !errors.declaration if (!isDonation.value) {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
}
return Object.values(errors).every((message) => message === '')
} }
async function submit() { async function submit() {
@@ -61,14 +110,21 @@ async function submit() {
saving.value = true saving.value = true
try { // Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', { const body = isDonation.value
method: 'POST', ? {donation: true, declarationAccepted: form.declarationAccepted}
body: { : {
donation: false,
accountOwner: form.accountOwner, accountOwner: form.accountOwner,
accountIban: form.accountIban, accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted, declarationAccepted: form.declarationAccepted,
}, accountDeclarationAccepted: form.accountDeclarationAccepted,
}
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body,
}) })
if (!response) { if (!response) {
@@ -88,6 +144,7 @@ async function submit() {
} }
toast.success(response.message) toast.success(response.message)
donated.value = isDonation.value
state.value = 'accepted' state.value = 'accepted'
} finally { } finally {
saving.value = false saving.value = false
@@ -128,7 +185,12 @@ async function submit() {
</table> </table>
<template v-if="state === 'accepted'"> <template v-if="state === 'accepted'">
<p class="hint"> <p v-if="donated" class="hint">
Du hast den Betrag gespendet<span v-if="props.acceptedAt"> (am {{ props.acceptedAt }})</span>
&ndash; vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<p v-else class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>. Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }} an sie: {{ props.eventEmail }}
@@ -141,9 +203,98 @@ async function submit() {
dich bitte an die Aktionsleitung: {{ props.eventEmail }} dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p> </p>
<h3>Auf welches Konto sollen wir überweisen?</h3>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<!--
Erste Frage: Wer spendet, braucht kein Konto anzugeben. Deshalb steht sie
vor allem anderen.
-->
<h3>Möchtest du den Betrag stattdessen spenden?</h3>
<p class="choice-hint">
Spendest du, verbleibt der Betrag beim Verband und kommt unserer Arbeit
zugute. Wir brauchen dann keine Bankverbindung von dir.
</p>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: decision === 'donation'}"
@click="choose('donation')"
>Ja, spenden</button>
<button
type="button"
class="choice"
:class="{active: decision === 'payout'}"
@click="choose('payout')"
>Nein, bitte erstatten</button>
</div>
<!-- Spendenweg: nur der Verzicht, keine Kontoangaben. -->
<template v-if="decision === 'donation'">
<div class="declaration">
<input
id="refund-donation-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_DONATION"
belongs-to="refund-donation-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button
v-if="form.declarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Betrag spenden' }}
</button>
</template>
<!--
Auszahlungsweg: Erstattet wird ausschließlich auf das Konto, von dem der
Beitrag kam. Das wird gefragt, bevor überhaupt eine IBAN eingegeben wird --
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
-->
<template v-else-if="decision === 'payout'">
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<template v-if="sameAccount === false">
<p class="hint">
So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
<template v-else-if="sameAccount === true">
<div class="field"> <div class="field">
<label for="account-owner">Kontoinhaber*in</label> <label for="account-owner">Kontoinhaber*in</label>
<input <input
@@ -163,9 +314,9 @@ async function submit() {
</div> </div>
<!-- <!--
Die Erklärung, die anschließend auf dem Eigenbeleg steht. Sie muss hier Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
gelesen und angekreuzt werden -- sonst schriebe der Beleg dem Teili eine müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
Zusicherung zu, die er nie abgegeben hat. dem Teili Zusicherungen zu, die er nie abgegeben hat.
--> -->
<template v-if="accountComplete"> <template v-if="accountComplete">
<div class="declaration"> <div class="declaration">
@@ -181,10 +332,23 @@ async function submit() {
</div> </div>
<ErrorText :message="errors.declaration" /> <ErrorText :message="errors.declaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der Knopf <div class="declaration">
unter dem Finger. --> <input
id="refund-account-declaration"
v-model="form.accountDeclarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT"
belongs-to="refund-account-declaration"
/>
</div>
<ErrorText :message="errors.accountDeclaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der
Knopf unter dem Finger. -->
<button <button
v-if="form.declarationAccepted" v-if="form.declarationAccepted && form.accountDeclarationAccepted"
class="button" class="button"
type="submit" type="submit"
:disabled="saving" :disabled="saving"
@@ -192,6 +356,8 @@ async function submit() {
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }} {{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button> </button>
</template> </template>
</template>
</template>
</form> </form>
</template> </template>
</template> </template>
@@ -235,6 +401,34 @@ h3 {
padding: 6px 0; padding: 6px 0;
} }
.choice-hint {
margin-bottom: 12px;
font-size: 0.9rem;
color: #4b5563;
}
.choices {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.choice {
flex: 1;
padding: 10px 12px;
border: 1px solid #d8dde6;
border-radius: 0;
background: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.choice.active {
border-color: #1a4799;
background: #eef2fa;
font-weight: bold;
}
.field { .field {
margin-bottom: 16px; margin-bottom: 16px;
} }
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
/**
* Gründe für eine Reisekostenabrechnung -- DB-gestützt (analog {@see RetentionReason}), damit die
* Auswahl ohne Deployment pflegbar bleibt.
*
* Gespeichert wird an der Abrechnung der Slug, bei `other` stattdessen der Freitext: Der Slug sagt für
* sich nichts aus, der Text alles. {@see \App\Models\Invoice::travelReasonText()} löst beides auf.
*
* @property string $slug
* @property string $name
* @property bool $requires_note
* @property int $sort_order
*/
class TravelReason extends CommonModel
{
public const string EVENT_TRAVEL = 'event_travel';
public const string MATERIAL_TRANSPORT = 'material_transport';
public const string PURCHASE = 'purchase';
public const string OTHER = 'other';
protected $table = 'travel_reasons';
protected $primaryKey = 'slug';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'slug',
'name',
'requires_note',
'sort_order',
];
protected $casts = [
'requires_note' => 'boolean',
'sort_order' => 'integer',
];
/**
* Ein gespeicherter Wert als lesbarer Text: der Name des Grundes, sonst der Wert selbst.
*
* Der ist dann der Freitext von "Anderer Grund" oder stammt aus der Zeit vor der Auswahl -- beides
* ist bereits die Antwort auf die Frage und braucht keine Übersetzung.
*/
public static function text(?string $value): string
{
return self::find($value)?->name ?? (string) $value;
}
/**
* Optionen für das Frontend. `requiresNote` steuert dort das Freitextfeld.
*
* @return array<int, array{value: string, label: string, requiresNote: bool}>
*/
public static function options(): array
{
return self::orderBy('sort_order')->get()
->map(static fn (self $reason): array => [
'value' => $reason->slug,
'label' => $reason->name,
'requiresNote' => $reason->requires_note,
])
->all();
}
}
@@ -31,7 +31,9 @@ class RefundAcceptedMail extends Mailable
{ {
return new Envelope( return new Envelope(
subject: sprintf( subject: sprintf(
'Deine Angaben zur Rückerstattung für %s', $this->refund->isDonation()
? 'Deine Spende statt Rückerstattung für %s'
: 'Deine Angaben zur Rückerstattung für %s',
$this->participant->event()->first()->name $this->participant->event()->first()->name
), ),
); );
@@ -54,6 +56,9 @@ class RefundAcceptedMail extends Mailable
'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, 'invoiceNumber' => $invoice?->invoice_number,
// Wurde gespendet, gibt es keine Bankverbindung und keine Überweisung, die angekündigt
// werden könnte. Steht in der Abrechnung, die hier ohnehin gelesen wird.
'donation' => (bool) $invoice?->donation,
// Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist. // Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist.
'hasRetention' => $this->refund->hasRetention(), 'hasRetention' => $this->refund->hasRetention(),
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro', 'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
+76 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType; use App\Enumerations\InvoiceType;
use App\Enumerations\TravelReason;
use App\Scopes\InstancedModel; use App\Scopes\InstancedModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $status * @property string $status
* @property string $type * @property string $type
* @property string $type_other * @property string $type_other
* @property string $purpose
* @property boolean $donation * @property boolean $donation
* @property string $user_id * @property string $user_id
* @property string $contact_name * @property string $contact_name
@@ -26,8 +28,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $comment * @property string $comment
* @property string $changes * @property string $changes
* @property string $travel_direction * @property string $travel_direction
* @property boolean $passengers * @property boolean $passengers Nicht mehr erfasst -- siehe Hinweis unten
* @property boolean $transportation * @property boolean $transportation Nicht mehr erfasst -- siehe Hinweis unten
* @property string $document_filename * @property string $document_filename
* @property string $approved_by * @property string $approved_by
* @property string $approved_at * @property string $approved_at
@@ -35,6 +37,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $denied_by * @property string $denied_by
* @property string $denied_at * @property string $denied_at
* @property string $denied_reason * @property string $denied_reason
*
* `passengers` ("Ich habe Personen mitgenommen") und `transportation` ("Ich habe Material transportiert")
* werden seit dem Ausbau des Reisekosten-Formulars nicht mehr erfasst: Sie waren reine Ja/Nein-Angaben
* ohne Wirkung auf den Betrag -- eine Kostenstelle führt genau eine Kilometerpauschale, es gibt also
* keinen zweiten Satz, auf den sie umschalten könnten. Wer gereist ist, steht jetzt im `purpose`.
* Die Spalten bleiben für die Altdaten stehen; sollen sie je wiederkommen, dann als Angaben, die in die
* Berechnung eingehen (Mitnahmeentschädigung: eine Anzahl, kein Häkchen).
*/ */
class Invoice extends InstancedModel class Invoice extends InstancedModel
{ {
@@ -45,6 +54,7 @@ class Invoice extends InstancedModel
'status', 'status',
'type', 'type',
'type_other', 'type_other',
'purpose',
'donation', 'donation',
'user_id', 'user_id',
'contact_name', 'contact_name',
@@ -71,6 +81,70 @@ class Invoice extends InstancedModel
'denied_reason', 'denied_reason',
]; ];
/**
* Der Verwendungszweck für Überweisung, Buchungstext und Anzeige.
*
* Der Freitext gewinnt, wenn einer erfasst wurde. Sonst benennt der Text den Vorgang: eine
* Beitragserstattung ist keine Auslage des Teilis, sondern die Rücknahme seiner Zahlung -- auf dem
* Kontoauszug muss der Unterschied erkennbar sein. Genannt wird die Abrechnungsnummer, und zwar als
* Belegnummer: unter "Rechnungsnummer" gibt es sie nirgends.
*/
public function paymentPurposeText() : string {
if ($this->payment_purpose !== null) {
return $this->payment_purpose;
}
$subject = $this->type === InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND
? 'Beitragserstattung'
: 'Auslagenerstattung';
return $subject . ' Belegnummer ' . $this->invoice_number;
}
/**
* Wofür der Beleg steht -- der "Zahlungsgrund" der Beleglisten und der "Zweck" der EüR-Anlage.
*
* Erfasst wird er beim Anlegen und steht danach in der eigenen Spalte: eine Angabe, keine Ableitung.
* Nur so lässt er sich korrigieren, ohne dass ihn der nächste Vorgang wieder überschreibt.
*
* Bestandsbelege haben die Spalte leer -- für sie wird weiter abgeleitet, bei Fahrtkosten aus
* Reisegrund und `contact_name`, weil es die Frage "wer ist gefahren" damals nicht gab. Bleibt auch
* dabei nichts übrig, bleibt der Text leer; ein "--" würde in einer Belegliste nur Platz kosten.
*/
public function purposeText() : string {
if (trim((string) $this->purpose) !== '') {
return $this->purpose;
}
return self::joinPurposeParts(
$this->type === InvoiceType::INVOICE_TYPE_TRAVELLING
? [$this->travelReasonText(), $this->contact_name]
: [$this->type_other]
);
}
/**
* Der Reisegrund als lesbarer Text.
*
* Der Name des gewählten Grundes -- oder der Wert selbst, wenn er zu keinem passt. Der ist dann der
* Freitext von "Anderer Grund" oder stammt aus der Zeit vor der Auswahl.
*/
public function travelReasonText() : string {
return TravelReason::text($this->travel_reason);
}
/**
* Setzt einen Zahlungsgrund aus seinen Teilen zusammen: leere fallen weg, damit kein "" führt
* oder hängt.
*
* @param array<int, ?string> $parts
*/
public static function joinPurposeParts(array $parts) : string {
$parts = array_map(fn ($part) => trim((string) $part), $parts);
return implode(' — ', array_filter($parts, fn ($part) => $part !== ''));
}
public function costUnit() : BelongsTo{ public function costUnit() : BelongsTo{
return $this->belongsTo(CostUnit::class); return $this->belongsTo(CostUnit::class);
} }
+12
View File
@@ -117,6 +117,18 @@ class ParticipantRefund extends InstancedModel
return $this->captured_by !== null; return $this->captured_by !== null;
} }
/**
* Ob auf die Auszahlung verzichtet und der Betrag gespendet wurde.
*
* Steht in der Abrechnung und nicht am Vorgang -- wie der Auszahlungsstand, aus demselben Grund: eine
* zweite Spalte könnte davon abweichen. Vor dem Einreichen gibt es keine Abrechnung und nichts zu
* entscheiden, dann ist die Antwort `false`.
*/
public function isDonation(): bool
{
return (bool) $this->invoice()->first()?->donation;
}
public function isPending(): bool public function isPending(): bool
{ {
return $this->status === self::STATUS_PENDING; return $this->status === self::STATUS_PENDING;
+6
View File
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType; use App\Enumerations\InvoiceType;
use App\Enumerations\RefundReason; use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason; use App\Enumerations\RetentionReason;
use App\Enumerations\TravelReason;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\Tenant; use App\Models\Tenant;
@@ -211,6 +212,11 @@ class GlobalDataProvider {
return response()->json(RetentionReason::options()); return response()->json(RetentionReason::options());
} }
/** Auswahl der Reisegründe für die Reisekostenabrechnung. */
public function getTravelReasons() : JsonResponse {
return response()->json(TravelReason::options());
}
public function getEventSettingData(Request $request) : JsonResponse { public function getEventSettingData(Request $request) : JsonResponse {
return response()->json( return response()->json(
[ [
+5 -4
View File
@@ -30,6 +30,7 @@ class InvoiceResource {
} }
$returnData['invoiceTypeShort'] = $this->invoice->invoiceType()->name; $returnData['invoiceTypeShort'] = $this->invoice->invoiceType()->name;
$returnData['purpose'] = $this->invoice->purposeText();
$returnData['costUnitName'] = $this->invoice->costUnit()->first()->name; $returnData['costUnitName'] = $this->invoice->costUnit()->first()->name;
$returnData['invoiceNumber'] = $this->invoice->invoice_number; $returnData['invoiceNumber'] = $this->invoice->invoice_number;
$returnData['contactName'] = $this->invoice->contact_name; $returnData['contactName'] = $this->invoice->contact_name;
@@ -39,7 +40,7 @@ class InvoiceResource {
$returnData['id'] = $this->invoice->id; $returnData['id'] = $this->invoice->id;
$returnData['donation'] = $this->invoice->donation; $returnData['donation'] = $this->invoice->donation;
$returnData['externalPayment'] = null !== $this->invoice->payment_purpose; $returnData['externalPayment'] = null !== $this->invoice->payment_purpose;
$returnData['paymentPurpose'] = $this->invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $returnData['invoiceNumber']; $returnData['paymentPurpose'] = $this->invoice->paymentPurposeText();
$returnData['accountOwner'] = $this->invoice->contact_bank_owner ?? '--'; $returnData['accountOwner'] = $this->invoice->contact_bank_owner ?? '--';
$returnData['accountIban'] = $this->invoice->contact_bank_iban ?? '--'; $returnData['accountIban'] = $this->invoice->contact_bank_iban ?? '--';
$returnData['status'] = $this->invoice->status; $returnData['status'] = $this->invoice->status;
@@ -49,11 +50,11 @@ class InvoiceResource {
$returnData['changes'] = $this->invoice->changes ?? '--'; $returnData['changes'] = $this->invoice->changes ?? '--';
$returnData['deniedReason'] = $this->invoice->denied_reason ?? '--'; $returnData['deniedReason'] = $this->invoice->denied_reason ?? '--';
$returnData['travelDirection'] = $this->invoice->travel_direction ?? '--'; $returnData['travelDirection'] = $this->invoice->travel_direction ?? '--';
$returnData['travelReason'] = $this->invoice->travel_reason ?? '--'; $returnData['travelReason'] = $this->invoice->travel_reason === null
? '--'
: $this->invoice->travelReasonText();
$returnData['distance'] = $this->invoice->distance ?? '--'; $returnData['distance'] = $this->invoice->distance ?? '--';
$returnData['distanceAllowance'] = new Amount($this->invoice->costUnit()->first()->distance_allowance, '')->toString(); $returnData['distanceAllowance'] = new Amount($this->invoice->costUnit()->first()->distance_allowance, '')->toString();
$returnData['passengers'] = $this->invoice->passengers ? 'Ja' : 'Nein';
$returnData['transportation'] = $this->invoice->transportation ? 'Ja' : 'Nein';
$returnData['travelRoute'] = $this->invoice->travel_direction; $returnData['travelRoute'] = $this->invoice->travel_direction;
$returnData['costUnitId'] = $this->invoice->cost_unit_id; $returnData['costUnitId'] = $this->invoice->cost_unit_id;
$returnData['amountPlain'] = new Amount($this->invoice->amount, '')->toString(); $returnData['amountPlain'] = new Amount($this->invoice->amount, '')->toString();
@@ -40,6 +40,9 @@ class ParticipantRefundResource extends JsonResource
'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. // Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number, 'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
// Ob überhaupt ausgezahlt wird: Bei einer Spende bleibt der Betrag beim Verband. Steht wie
// die Belegnummer in der Abrechnung.
'donation' => $this->resource->isDonation(),
]; ];
} }
} }
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Die beiden Erklärungen, die auf der Erstattungsseite neben der bestehenden stehen.
*
* `…_ACCOUNT` gilt nur im Auszahlungsweg: Zurückgezahlt wird ausschließlich auf das Konto, von dem der
* Beitrag kam -- sonst ließe sich über eine Erstattung Geld auf ein fremdes Konto umleiten, und die
* Zahlung wäre ihrer Herkunft nicht mehr zuzuordnen.
*
* `…_DONATION` tritt an die Stelle beider Sätze, wenn gespendet wird: Dann gibt es kein Konto, und die
* Person erklärt statt der Kontoangabe den Verzicht.
*
* Wie bei {@see 2026_09_04_140010_add_participant_refund_confirmation_text.php} liegen sie in
* `page_texts` und nicht in der Dokumentvorlage, damit Seite und Beleg denselben Wortlaut zeigen.
* Geschrieben wird nur, was fehlt -- eine bereits angepasste Fassung bleibt unangetastet.
*/
return new class extends Migration {
/** @var array<string, string> */
private const array TEXTS = [
'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT' => 'Ich bestätige, dass das angegebene Konto dasselbe '
. 'ist, von dem der Teilnahmebeitrag gezahlt wurde.',
'CONFIRMATION_PARTICIPANT_REFUND_DONATION' => 'Ich verzichte auf die Auszahlung des genannten '
. 'Betrags und spende ihn an den Verband. Mir ist bewusst, dass dieser Verzicht nicht '
. 'rückgängig gemacht werden kann.',
];
public function up(): void
{
foreach (self::TEXTS as $name => $content) {
if (DB::table('page_texts')->where('name', $name)->first() !== null) {
continue;
}
DB::table('page_texts')->insert([
'name' => $name,
'content' => $content,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
DB::table('page_texts')->whereIn('name', array_keys(self::TEXTS))->delete();
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Der Zahlungsgrund als eigene Angabe: die Spalte "Zahlungsgrund" der Beleglisten und der "Zweck" der
* EüR-Anlage. Bislang wurde er aus `type_other` bzw. Reisegrund und Kontaktname abgeleitet -- damit war
* er weder erfassbar noch korrigierbar.
*
* Kein Backfill: `null` heißt "ableiten wie bisher", und genau das brauchen die Bestandsbelege.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->string('purpose')->nullable()->default(null)->after('type_other');
});
}
public function down(): void
{
}
};
@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gründe für eine Reisekostenabrechnung.
*
* Vorher ein Freitextfeld: Jede Fahrt hieß anders, und auswerten ließ sich nichts. Aufgebaut wie
* {@see \App\Enumerations\RetentionReason}: app-weite Stammdaten, `slug` als Schlüssel, `requires_note`
* für den einen Grund, der ohne Erläuterung nichts aussagt.
*
* `invoices.travel_reason` bekommt bewusst keinen Fremdschlüssel: Bei "Anderer Grund" steht dort der
* Text und nicht der Schlüssel -- der Schlüssel `other` sagt für sich nichts aus. Dieselbe Spalte führt
* damit weiter den Freitext der Bestandsbelege, ohne dass etwas umgeschrieben werden muss.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('travel_reasons', function (Blueprint $table) {
$table->string('slug')->primary();
$table->string('name');
$table->boolean('requires_note')->default(false);
$table->integer('sort_order')->default(0);
$table->timestamps();
});
DB::table('travel_reasons')->insert([
[
'slug' => 'event_travel',
'name' => 'An-/Abreise zur Veranstaltung',
'requires_note' => false,
'sort_order' => 10,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'material_transport',
'name' => 'Materialtransport',
'requires_note' => false,
'sort_order' => 20,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'purchase',
'name' => 'Einkauf',
'requires_note' => false,
'sort_order' => 30,
'created_at' => now(),
'updated_at' => now(),
],
[
'slug' => 'other',
'name' => 'Anderer Grund',
'requires_note' => true,
'sort_order' => 40,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('travel_reasons');
}
};
@@ -16,8 +16,12 @@
-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht, -- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht,
-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil). -- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil).
-- --
-- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili -- Der Erklärungssatz kommt aus den Seitentexten CONFIRMATION_PARTICIPANT_REFUND(_ACCOUNT bzw. _DONATION),
-- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text. -- denselben, die der Teili vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als
-- fester Text.
--
-- Ebenso der Einleitungssatz: Er lautet bei einer Spende anders als bei einer Auszahlung, und {if:…} kennt
-- keine Verneinung, mit der sich der eine gegen den anderen tauschen ließe.
DELETE FROM document_templates WHERE document_type = 'participant_refund'; DELETE FROM document_templates WHERE document_type = 'participant_refund';
@@ -129,7 +133,7 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
<td>{unregistered_at}</td> <td>{unregistered_at}</td>
</tr>{/if:unregistered_at} </tr>{/if:unregistered_at}
</table>', 80, 1, NOW(), NOW()), </table>', 80, 1, NOW(), NOW()),
('participant_refund', 'body', '<div class="intro-text">Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die R&uuml;ckerstattung wie folgt:</div> ('participant_refund', 'body', '<div class="intro-text">{intro_text}</div>
{details_table} {details_table}
@@ -2,7 +2,25 @@
<html> <html>
<body> <body>
<h1>Hallo {{$name}}!</h1> <h1>Hallo {{$name}}!</h1>
@if ($donation)
@if ($capturedByManagement) @if ($capturedByManagement)
<p>
die Aktionsleitung hat vermerkt, dass du auf die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" verzichtest und den Betrag spendest.
<strong>Du musst nichts weiter tun.</strong>
</p>
<p>
<strong>Bitte prüfe die unten stehenden Angaben.</strong> Stimmt etwas nicht, melde dich
bitte umgehend bei der Aktionsleitung.
</p>
@else
<p>
vielen Dank &ndash; du hast auf die Rückerstattung deines Teilnahmebeitrags zur Veranstaltung
"{{$eventTitle}}" verzichtet und den Betrag gespendet.
<strong>Du musst nichts weiter tun.</strong>
</p>
@endif
@elseif ($capturedByManagement)
<p> <p>
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die Veranstaltung "{{$eventTitle}}" erfasst. <strong>Du musst nichts weiter tun</strong>: Die
@@ -34,6 +52,12 @@
<td style="padding: 4px 0;">{{$invoiceNumber}}</td> <td style="padding: 4px 0;">{{$invoiceNumber}}</td>
</tr> </tr>
@endif @endif
@if ($donation)
<tr>
<td style="padding: 4px 16px 4px 0; color: #555;">Auszahlung:</td>
<td style="padding: 4px 0;">als Spende beim Verband verblieben</td>
</tr>
@else
<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>
@@ -42,6 +66,7 @@
<td style="padding: 4px 16px 4px 0; color: #555;">IBAN:</td> <td style="padding: 4px 16px 4px 0; color: #555;">IBAN:</td>
<td style="padding: 4px 0;">{{$accountIban}}</td> <td style="padding: 4px 0;">{{$accountIban}}</td>
</tr> </tr>
@endif
</table> </table>
@if ($hasRetention) @if ($hasRetention)
@@ -65,10 +90,16 @@
</p> </p>
@endif @endif
@if ($donation)
<p>
Vielen Dank, dass du uns den Betrag überlässt &ndash; er kommt unserer Arbeit zugute.
</p>
@else
<p> <p>
Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen. Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen.
Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung. Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.
</p> </p>
@endif
<p> <p>
@include('emails.subparts.disclaimer') @include('emails.subparts.disclaimer')
@@ -29,8 +29,19 @@
folgenden Link ein: folgenden Link ein:
</p> </p>
<p style="padding: 10px 12px; border-left: 3px solid #f5c400; background-color: #fffef5;">
<strong>Wichtig:</strong> Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
gezahlt wurde. Wurde er von einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils &ndash;,
gib bitte dieses an.
</p>
<p> <p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen</a> Du kannst den Betrag <strong>stattdessen auch spenden</strong>. Dann brauchen wir keine
Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
</p>
<p>
<a href="{{$link}}" style="display: inline-block; padding: 10px 18px; background: #1a4799; color: #ffffff; text-decoration: none; border-radius: 3px;">Bankverbindung eintragen oder spenden</a>
</p> </p>
<p style="font-size: 12px; color: #555;"> <p style="font-size: 12px; color: #555;">
@@ -39,9 +50,9 @@
</p> </p>
<p> <p>
Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag Solange uns deine Bankverbindung nicht vorliegt und du dich auch nicht für die Spende entschieden
selbst steht fest und lässt sich über den Link nicht ändern &ndash; wenn du dazu Fragen hast, wende hast, können wir den Vorgang nicht abschließen. Der Betrag selbst steht fest und lässt sich über den
dich bitte an die Aktionsleitung. Link nicht ändern &ndash; wenn du dazu Fragen hast, wende dich bitte an die Aktionsleitung.
</p> </p>
<p> <p>
+1
View File
@@ -58,6 +58,7 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']); Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']);
Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']); Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']);
Route::get('/retrieve-retention-reasons', [GlobalDataProvider::class, 'getRetentionReasons']); Route::get('/retrieve-retention-reasons', [GlobalDataProvider::class, 'getRetentionReasons']);
Route::get('/retrieve-travel-reasons', [GlobalDataProvider::class, 'getTravelReasons']);
}); });
}); });
@@ -260,16 +260,63 @@ class EventIncomeSurplusStatementTest extends TestCase
typeOther: 'Bastelmaterial' typeOther: 'Bastelmaterial'
); );
$this->assertSame('Bastelmaterial — Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']); $this->assertSame('Bastelmaterial', $this->group('Programmkosten')['rows'][0]['purpose']);
} }
public function test_an_older_receipt_without_the_purchase_note_falls_back_to_the_comment(): void public function test_a_recorded_purpose_wins(): void
{ {
// Belege von vor der Pflichtangabe haben `type_other` leer. // Der Regelfall für neue Belege: Der Zahlungsgrund wird beim Einreichen erfasst und steht in
// seiner eigenen Spalte -- korrigierbar, ohne dass ihn etwas wieder überschreibt.
$this->makeEvent();
$this->makeInvoice(
InvoiceType::INVOICE_TYPE_PROGRAM,
100.0,
InvoiceStatus::INVOICE_STATUS_EXPORTED,
typeOther: 'Bastelmaterial',
purpose: 'Material für den Bastelnachmittag'
);
$this->assertSame(
'Material für den Bastelnachmittag',
$this->group('Programmkosten')['rows'][0]['purpose']
);
}
public function test_travel_costs_name_the_reason_and_who_travelled(): void
{
// Bei Fahrtkosten bleibt `type_other` leer -- die Strecke landet in `travel_direction`. Der Zweck
// wird deshalb wie in der Beleg-Übersicht ermittelt, über Invoice::purposeText().
$this->makeEvent();
// Der Typ nur hier, nicht im setUp(): dort stehen bewusst drei Typen, deren Gliederung ein
// anderer Test wörtlich prüft.
DB::table('invoice_types')->insert([
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'name' => 'Fahrtkosten',
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
$this->makeInvoice(
InvoiceType::INVOICE_TYPE_TRAVELLING,
88.0,
InvoiceStatus::INVOICE_STATUS_EXPORTED,
travelReason: 'Landeslager'
);
$this->assertSame('Landeslager — Mika Muster', $this->group('Fahrtkosten')['rows'][0]['purpose']);
}
public function test_the_comment_is_no_longer_part_of_the_purpose(): void
{
// Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem
// Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben
// hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren.
$this->makeEvent(); $this->makeEvent();
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
$this->assertSame('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']); $this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']);
} }
/* /*
@@ -471,7 +518,9 @@ class EventIncomeSurplusStatementTest extends TestCase
float $amount, float $amount,
string $status, string $status,
bool $donation = false, bool $donation = false,
?string $typeOther = null ?string $typeOther = null,
?string $travelReason = null,
?string $purpose = null
): Invoice { ): Invoice {
return Invoice::create([ return Invoice::create([
'tenant' => $this->tenant->slug, 'tenant' => $this->tenant->slug,
@@ -480,6 +529,8 @@ class EventIncomeSurplusStatementTest extends TestCase
'status' => $status, 'status' => $status,
'type' => $type, 'type' => $type,
'type_other' => $typeOther, 'type_other' => $typeOther,
'purpose' => $purpose,
'travel_reason' => $travelReason,
'donation' => $donation, 'donation' => $donation,
'contact_name' => 'Mika Muster', 'contact_name' => 'Mika Muster',
'comment' => 'Materialkauf', 'comment' => 'Materialkauf',
+329
View File
@@ -0,0 +1,329 @@
<?php
namespace Tests\Feature;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceCommand;
use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\Tenant;
use App\Models\User;
use App\Resources\InvoiceResource;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Zahlungsgrund: die gleichnamige Spalte der Beleglisten und der "Zweck" der EüR-Anlage.
*
* Er wird beim Einreichen erfasst und steht danach in einer eigenen Spalte -- eine Angabe, keine
* Ableitung. Nur Bestandsbelege, die vor der Spalte entstanden sind, leiten ihn noch her.
*/
class InvoicePurposeTest 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('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
foreach ([InvoiceStatus::INVOICE_STATUS_NEW, InvoiceStatus::INVOICE_STATUS_APPROVED] as $status) {
DB::table('invoice_status')->insert(['slug' => $status]);
}
// Die Beitragserstattung bringt die Migration mit; die beiden anderen Typen nicht.
foreach ([
InvoiceType::INVOICE_TYPE_TRAVELLING => 'Fahrtkosten',
InvoiceType::INVOICE_TYPE_OTHER => 'Sonstige Kosten',
] as $slug => $name) {
DB::table('invoice_types')->insert([
'slug' => $slug,
'name' => $name,
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
}
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
Mail::fake();
}
private function makeCostUnit(): CostUnit
{
return 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,
]);
}
private function makeUser(): User
{
return User::create([
'username' => 'kassenwart-' . uniqid() . '@example.com',
'email' => 'kassenwart-' . uniqid() . '@example.com',
'firstname' => 'Kim',
'lastname' => 'Kasse',
'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,
]);
}
/**
* Ein Beleg direkt in der Datenbank -- so sehen Bestandsbelege aus, deren `purpose` leer ist.
*/
private function makeInvoice(array $attributes): Invoice
{
$this->sequence++;
return Invoice::create(array_merge([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => sprintf('2026-%04d', $this->sequence),
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'contact_name' => 'Max Mustermann',
'amount' => 42.0,
], $attributes));
}
private function purposeOf(array $attributes): string
{
return new InvoiceResource($this->makeInvoice($attributes))->toArray()['purpose'];
}
/*
|--------------------------------------------------------------------------
| Erfasst beim Einreichen
|--------------------------------------------------------------------------
*/
/**
* Der Weg, den jede eingereichte Abrechnung nimmt. `contactEmail` bleibt leer, damit kein
* Bestätigungsmailversand mitläuft.
*/
private function submit(string $type, ?string $purchase = null, ?string $travelReason = null, ?string $travellers = null): Invoice
{
return new CreateInvoiceCommand(new CreateInvoiceRequest(
costUnit: $this->makeCostUnit(),
contactName: 'Max Mustermann',
invoiceType: $type,
totalAmount: 42.0,
receiptFile: null,
isDonation: false,
invoiceTypeExtended: $purchase,
travelReason: $travelReason,
travellers: $travellers,
))->execute()->invoice;
}
public function test_an_expense_records_what_was_bought(): void
{
$this->assertSame(
'Bastelmaterial Sippenstunde',
$this->submit(InvoiceType::INVOICE_TYPE_OTHER, purchase: 'Bastelmaterial Sippenstunde')->purpose
);
}
public function test_travel_costs_record_the_reason_and_who_travelled(): void
{
// Wer gefahren ist, wird gefragt und nicht aus dem Kontaktnamen geraten: Es kann jemand anderes
// sein als der, auf dessen Konto das Geld geht.
$invoice = $this->submit(
InvoiceType::INVOICE_TYPE_TRAVELLING,
travelReason: 'Landeslager',
travellers: 'Mika und Kim'
);
$this->assertSame('Landeslager — Mika und Kim', $invoice->purpose);
}
public function test_travel_costs_without_travellers_record_only_the_reason(): void
{
// Ohne Login steht im Formular niemand drin -- das darf den Beleg nicht aufhalten.
$invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager');
$this->assertSame('Landeslager', $invoice->purpose);
}
public function test_a_refund_records_no_purpose(): void
{
// Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie
// dieselbe Regel wie für Bestandsbelege.
$this->assertNull($this->submit(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->purpose);
}
/*
|--------------------------------------------------------------------------
| Die erfasste Spalte gewinnt
|--------------------------------------------------------------------------
*/
public function test_a_recorded_purpose_beats_the_purchase_note(): void
{
$this->assertSame('Zeltplatzmiete', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_OTHER,
'type_other' => 'Bastelmaterial',
'purpose' => 'Zeltplatzmiete',
]));
}
public function test_a_recorded_purpose_beats_the_travel_derivation(): void
{
$this->assertSame('Bundeslager — Mika und Kim', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'travel_reason' => 'Landeslager',
'purpose' => 'Bundeslager — Mika und Kim',
]));
}
/*
|--------------------------------------------------------------------------
| Bestandsbelege leiten weiter ab
|--------------------------------------------------------------------------
*/
public function test_an_expense_shows_what_was_bought(): void
{
$this->assertSame('Bastelmaterial Sippenstunde', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_OTHER,
'type_other' => 'Bastelmaterial Sippenstunde',
]));
}
public function test_travel_costs_show_the_reason_and_who_travelled(): void
{
// `type_other` bleibt bei Fahrtkosten leer -- die Strecke landet in `travel_direction`. Wer
// gefahren ist, wurde damals nicht gefragt; dafür steht der Kontaktname.
$this->assertSame('Landeslager — Max Mustermann', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'travel_direction' => 'Halle Leipzig',
'travel_reason' => 'Landeslager',
]));
}
public function test_travel_costs_without_a_reason_still_name_the_person(): void
{
// Altbestand: der Reisegrund wurde erst später zur Pflicht. Kein führendes " — ".
$this->assertSame('Max Mustermann', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'travel_direction' => 'Halle Leipzig',
]));
}
public function test_an_invoice_without_a_purpose_stays_empty(): void
{
// Beitragserstattungen entstehen ohne Freitext, ältere Belege haben keinen. Ein "--" würde in
// der Liste nur Platz kosten.
$this->assertSame('', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
]));
}
/*
|--------------------------------------------------------------------------
| Korrigierbar, und danach unangetastet
|--------------------------------------------------------------------------
*/
/**
* Der Korrektur-Weg der Kassenwart*in. `UpdateInvoiceCommand` gibt die Abrechnung am Ende frei --
* dafür braucht es einen eingeloggten Menschen.
*/
private function correct(Invoice $invoice, ?string $purpose): Invoice
{
$this->actingAs($this->makeUser());
new UpdateInvoiceCommand(new UpdateInvoiceRequest(
$invoice,
$invoice->comment,
$invoice->invoiceType(),
$invoice->costUnit()->first(),
Amount::fromString($invoice->amount),
$purpose
))->execute();
return $invoice->fresh();
}
public function test_a_corrected_purpose_is_stored_and_logged(): void
{
$invoice = $this->correct(
$this->makeInvoice(['type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial']),
'Material für den Bastelnachmittag'
);
$this->assertSame('Material für den Bastelnachmittag', $invoice->purpose);
$this->assertSame('Material für den Bastelnachmittag', $invoice->purposeText());
$this->assertStringContainsString('Zahlungsgrund geändert', $invoice->changes);
}
public function test_an_untouched_purpose_is_not_frozen(): void
{
// Das Formular ist mit dem angezeigten Text vorbelegt. Wer ihn unverändert abschickt, hat nichts
// geändert -- ein Bestandsbeleg behält seine leere Spalte und leitet weiter ab.
$invoice = $this->correct(
$this->makeInvoice(['type' => InvoiceType::INVOICE_TYPE_OTHER, 'type_other' => 'Bastelmaterial']),
'Bastelmaterial'
);
$this->assertNull($invoice->purpose);
$this->assertSame('Bastelmaterial', $invoice->purposeText());
$this->assertStringNotContainsString('Zahlungsgrund', (string) $invoice->changes);
}
public function test_the_comment_of_a_correction_stays_out_of_the_purpose(): void
{
// Die Anmerkung der Korrektur gehört auf den Beleg, nicht in den Zahlungsgrund.
$invoice = $this->makeInvoice([
'type' => InvoiceType::INVOICE_TYPE_OTHER,
'type_other' => 'Bastelmaterial',
'comment' => 'Betrag nach Rücksprache korrigiert',
]);
$this->assertSame('Bastelmaterial', $this->correct($invoice, 'Bastelmaterial')->purposeText());
}
}
+244
View File
@@ -9,6 +9,8 @@ use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand; use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
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\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailCommand;
use App\Domains\ParticipantRefund\Actions\ResendRefundMail\ResendRefundMailRequest;
use App\Enumerations\EfzStatus; use App\Enumerations\EfzStatus;
use App\Enumerations\CostUnitType; use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
@@ -232,12 +234,31 @@ class ParticipantRefundTest extends TestCase
string $owner = 'Mika Muster', string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051', string $iban = 'DE02120300000000202051',
bool $declarationAccepted = true, bool $declarationAccepted = true,
bool $accountDeclarationAccepted = true,
) { ) {
return new AcceptRefundCommand(new AcceptRefundRequest( return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund, refund: $refund,
accountOwner: $owner, accountOwner: $owner,
accountIban: $iban, accountIban: $iban,
declarationAccepted: $declarationAccepted, declarationAccepted: $declarationAccepted,
accountDeclarationAccepted: $accountDeclarationAccepted,
))->execute();
}
private function resend(?ParticipantRefund $refund)
{
return new ResendRefundMailCommand(new ResendRefundMailRequest($refund))->execute();
}
/** Der zweite Weg: Der Teili verzichtet auf die Auszahlung und spendet -- ohne Bankverbindung. */
private function acceptAsDonation(?ParticipantRefund $refund, bool $declarationAccepted = true)
{
return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: $declarationAccepted,
donation: true,
))->execute(); ))->execute();
} }
@@ -459,6 +480,105 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
} }
public function test_accept_is_rejected_without_the_account_declaration(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Erstattet wird nur auf das Konto, von dem der Beitrag kam. Fehlt die Bestätigung, ließe sich
// über eine Erstattung Geld auf ein fremdes Konto umleiten.
$response = $this->accept($refund, accountDeclarationAccepted: false);
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountDeclaration', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
$this->assertNull($refund->fresh()->account_iban);
}
public function test_the_account_declaration_cannot_be_skipped_over_http(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true,
])
->assertOk()
->assertJsonPath('status', 'error')
->assertJsonStructure(['error_types' => ['accountDeclaration']]);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
/*
|--------------------------------------------------------------------------
| Spenden statt auszahlen
|--------------------------------------------------------------------------
*/
public function test_a_donation_is_accepted_without_bank_details(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$response = $this->acceptAsDonation($refund);
$this->assertTrue($response->success);
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
$this->assertTrue($refund->fresh()->isDonation());
}
public function test_a_donation_still_needs_the_declaration(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Der Verzicht ist die Erklärung, die anschließend auf dem Beleg steht -- ohne sie nichts.
$response = $this->acceptAsDonation($refund, declarationAccepted: false);
$this->assertFalse($response->success);
$this->assertArrayHasKey('declaration', $response->errorTypes);
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
}
public function test_a_donation_ignores_bank_details_sent_along(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
// Wer im Formular erst ein Konto eintippt und dann doch spendet, soll es nicht hinterlassen.
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
donation: true,
))->execute();
$this->assertTrue($response->success);
$this->assertNull($refund->fresh()->account_owner);
$this->assertNull($refund->fresh()->account_iban);
}
public function test_a_donation_over_http_needs_no_iban(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'donation' => true,
'declarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success');
$this->assertTrue($refund->fresh()->isDonation());
}
public function test_the_page_shows_a_finished_donation_as_donated(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->acceptAsDonation($refund);
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'accepted')->where('donation', true));
}
public function test_the_public_page_serves_the_declaration_text_name(): void public function test_the_public_page_serves_the_declaration_text_name(): void
{ {
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -473,6 +593,19 @@ class ParticipantRefundTest extends TestCase
->assertSee('Ich versichere', false); ->assertSee('Ich versichere', false);
} }
public function test_the_public_page_serves_the_new_declaration_texts(): void
{
// Beide Erklärungen stehen auf der Seite, die ohne Login erreichbar ist -- käme eine davon
// nicht durch, stünde dort eine leere Checkbox, die sich trotzdem ankreuzen ließe.
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT)
->assertOk()
->assertSee('dass das angegebene Konto dasselbe ist', false);
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DONATION_DECLARATION_TEXT)
->assertOk()
->assertSee('verzichte auf die Auszahlung', false);
}
public function test_accept_requires_an_account_owner(): void public function test_accept_requires_an_account_owner(): void
{ {
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -558,6 +691,69 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
} }
/*
|--------------------------------------------------------------------------
| Die Mail noch einmal schicken
|--------------------------------------------------------------------------
*/
public function test_resend_sends_the_release_mail_again_without_touching_the_refund(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
$refund = $this->release($participant)->refund;
// Erst ab hier zählen: die Mails der Freigabe sind nicht gemeint.
Mail::fake();
$response = $this->resend($refund);
$this->assertTrue($response->success);
Mail::assertSent(RefundReleasedMail::class, 2);
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com'));
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com'));
// Der Vorgang bleibt, wie er war -- vorgemerkt wurde er beim ersten Mal.
$fresh = $refund->fresh();
$this->assertSame(ParticipantRefund::STATUS_PENDING, $fresh->status);
$this->assertSame($refund->token, $fresh->token);
$this->assertEquals($refund->released_at, $fresh->released_at);
}
public function test_resend_sends_only_one_mail_without_contact_person(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
Mail::fake();
$this->resend($refund);
Mail::assertSent(RefundReleasedMail::class, 1);
}
public function test_resend_is_rejected_after_acceptance(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->accept($refund);
Mail::fake();
$response = $this->resend($refund->fresh());
$this->assertFalse($response->success);
$this->assertStringContainsString('bereits bestätigt', $response->message);
Mail::assertNothingSent();
}
public function test_resend_is_rejected_after_a_cancellation(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
Mail::fake();
$response = $this->resend($refund->fresh());
$this->assertFalse($response->success);
$this->assertStringContainsString('abgebrochen', $response->message);
Mail::assertNothingSent();
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Der gezahlte Beitrag bleibt unangetastet | Der gezahlte Beitrag bleibt unangetastet
@@ -716,6 +912,7 @@ class ParticipantRefundTest extends TestCase
'accountOwner' => 'Mika Muster', 'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51', 'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true, 'declarationAccepted' => true,
'accountDeclarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success'); ])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban); $this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
@@ -770,6 +967,53 @@ class ParticipantRefundTest extends TestCase
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
} }
public function test_resend_over_http_sends_the_mail_again(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->actingAs($this->makeAdmin());
Mail::fake();
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
->assertOk()
->assertJsonPath('status', 'success');
Mail::assertSent(RefundReleasedMail::class, 1);
}
public function test_resend_requires_a_login(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
Mail::fake();
$this->post('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
->assertRedirect('/login');
Mail::assertNothingSent();
}
/** Der Token allein reicht nicht: nachschicken darf nur, wer die Veranstaltung verwalten kann. */
public function test_resend_is_forbidden_without_access_to_the_event(): void
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
$this->actingAs($this->makeParticipantUser());
Mail::fake();
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/resend-mail')
->assertForbidden();
Mail::assertNothingSent();
}
public function test_resend_on_an_unknown_token_is_forbidden(): void
{
$this->actingAs($this->makeAdmin());
$this->postJson('/api/v1/participant-refund/gibtesnicht/resend-mail')
->assertForbidden();
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Die Mails müssen sich auch wirklich rendern lassen | Die Mails müssen sich auch wirklich rendern lassen
+88
View File
@@ -412,6 +412,94 @@ class RefundDirectCaptureTest extends TestCase
$this->assertNotNull(ParticipantRefund::first()->invoice_id); $this->assertNotNull(ParticipantRefund::first()->invoice_id);
} }
/*
|--------------------------------------------------------------------------
| Der Teili spendet -- die Aktionsleitung nimmt es auf
|--------------------------------------------------------------------------
*/
public function test_a_donation_is_submitted_right_away_without_bank_details(): void
{
$participant = $this->makeParticipant();
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertTrue($response->success);
$this->assertStringContainsString('Spende', $response->message);
$refund = ParticipantRefund::first();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertTrue($refund->isDonation());
// Wer sie aufgenommen hat, wird auch hier festgehalten -- der Teili hat nichts angekreuzt.
$this->assertSame($this->management->id, $refund->captured_by);
$this->assertTrue((bool) Invoice::first()->donation);
}
public function test_a_donation_with_bank_details_is_refused(): void
{
$participant = $this->makeParticipant();
// Beides zusammen ist widersprüchlich: Lieber nachfragen, als eines stillschweigend zu verwerfen.
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Bankverbindung', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_donation_without_a_cost_unit_is_refused(): void
{
// Gebucht wird sie trotzdem -- ohne Kostenstelle gibt es nichts, worauf.
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
donation: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Kostenstelle', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_release_over_http_submits_a_donation(): void
{
$participant = $this->makeParticipant();
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
'amount' => '220,00',
'reason' => RefundReason::SICKNESS,
'retentionReason' => RetentionReason::CANCELLATION_FEE,
'accountOwner' => '',
'accountIban' => '',
'donation' => true,
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED)
->assertJsonPath('refund.donation', true);
$this->assertTrue((bool) Invoice::first()->donation);
}
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
{ {
$participant = $this->makeParticipant(); $participant = $this->makeParticipant();
+59 -5
View File
@@ -98,7 +98,8 @@ class RefundDocumentTest extends TestCase
DocumentTemplate::create([ DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY, 'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>' 'content' => '<p>{intro_text}</p>{details_table}'
. '<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>'
. '<p>{account_owner} / {account_iban}</p>' . '<p>{account_owner} / {account_iban}</p>'
. '<p>{paid_amount} / {invoice_number}</p>' . '<p>{paid_amount} / {invoice_number}</p>'
. '<p>{declaration_text}</p>', . '<p>{declaration_text}</p>',
@@ -195,15 +196,19 @@ class RefundDocumentTest extends TestCase
], $attributes)); ], $attributes));
} }
private function document(ParticipantRefund $refund) private function document(ParticipantRefund $refund, bool $donation = false)
{ {
return new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); return new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
)->execute();
} }
/** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */ /** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */
private function html(ParticipantRefund $refund): string private function html(ParticipantRefund $refund, bool $donation = false): string
{ {
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); $command = new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
);
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command); $number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number); $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
@@ -380,6 +385,55 @@ class RefundDocumentTest extends TestCase
$this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund())); $this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund()));
} }
public function test_the_payout_receipt_carries_the_account_declaration_too(): void
{
// Beide Sätze wurden angekreuzt, beide gehören auf den Beleg: Die Kontoerklärung ist der Grund,
// warum die Auszahlung auf genau dieses Konto zulässig ist.
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html);
$this->assertStringContainsString('dass das angegebene Konto dasselbe ist', $html);
}
/*
|--------------------------------------------------------------------------
| Der Beleg über eine Spende
|--------------------------------------------------------------------------
*/
public function test_a_donation_receipt_carries_the_waiver_instead(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringContainsString('verzichte auf die Auszahlung', $html);
// Die Erklärungen des Auszahlungswegs haben hier nichts zu suchen -- es gibt kein Konto.
$this->assertStringNotContainsString('dass das angegebene Konto dasselbe ist', $html);
}
public function test_a_donation_receipt_shows_no_bank_details(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringNotContainsString('IBAN', $html);
$this->assertStringContainsString('Auf die Auszahlung wird verzichtet', $html);
}
public function test_the_intro_sentence_follows_the_chosen_way(): void
{
$refund = $this->makeRefund();
$this->assertStringContainsString('bitte um die Rückerstattung', $this->html($refund));
$this->assertStringContainsString('spende ihn an den Verband', $this->html($refund, donation: true));
}
public function test_a_donation_receipt_is_a_pdf_as_well(): void
{
$response = $this->document($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertTrue($response->success);
$this->assertStringStartsWith('%PDF', $response->pdfContent);
}
public function test_free_text_reason_replaces_the_catalog_text(): void public function test_free_text_reason_replaces_the_catalog_text(): void
{ {
$html = $this->html($this->makeRefund([ $html = $this->html($this->makeRefund([
+142 -2
View File
@@ -70,6 +70,16 @@ class RefundInvoiceTest extends TestCase
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); 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('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]); DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
// Ein gewöhnlicher Aufwandstyp zum Vergleich; die Beitragserstattung bringt die Migration mit.
DB::table('invoice_types')->insert([
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'name' => 'Fahrtkosten',
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
@@ -211,6 +221,7 @@ class RefundInvoiceTest extends TestCase
float $amount = 220.0, float $amount = 220.0,
string $reason = RefundReason::SICKNESS, string $reason = RefundReason::SICKNESS,
?string $retentionReason = RetentionReason::CANCELLATION_FEE, ?string $retentionReason = RetentionReason::CANCELLATION_FEE,
bool $donation = false,
): ParticipantRefund { ): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent()); $participant ??= $this->makeParticipant($this->makeEvent());
@@ -221,11 +232,14 @@ class RefundInvoiceTest extends TestCase
retentionReason: $retentionReason, retentionReason: $retentionReason,
))->execute()->refund; ))->execute()->refund;
// Wer spendet, gibt keine Bankverbindung an -- der Server verlangt sie dann auch nicht.
new AcceptRefundCommand(new AcceptRefundRequest( new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund, refund: $refund,
accountOwner: 'Mika Muster', accountOwner: $donation ? '' : 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: $donation ? '' : 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
donation: $donation,
accountDeclarationAccepted: !$donation,
))->execute(); ))->execute();
return $refund->fresh(); return $refund->fresh();
@@ -252,6 +266,80 @@ class RefundInvoiceTest extends TestCase
$this->assertFalse((bool) $invoice->donation); $this->assertFalse((bool) $invoice->donation);
} }
/*
|--------------------------------------------------------------------------
| Spenden statt auszahlen
|--------------------------------------------------------------------------
*/
public function test_a_donated_refund_becomes_a_donated_invoice(): void
{
$this->runRefund(donation: true);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertTrue((bool) $invoice->donation);
// Derselbe Weg wie sonst: Nummernkreis, Status und Typ der Abrechnung ändern sich nicht.
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
}
public function test_a_donation_carries_no_bank_details(): void
{
$refund = $this->runRefund(donation: true);
// Weder am Vorgang noch an der Abrechnung -- und `null`, nicht Leerstring: Der SEPA-Export
// unterscheidet daran, ob es etwas auszuzahlen gibt.
$this->assertNull($refund->account_owner);
$this->assertNull($refund->account_iban);
$this->assertNull(Invoice::first()->contact_bank_owner);
$this->assertNull(Invoice::first()->contact_bank_iban);
}
public function test_the_refund_reads_the_donation_from_its_invoice(): void
{
// Am Vorgang steht sie nicht: Die Abrechnung führt sie, damit beide nicht auseinanderlaufen.
$this->assertTrue($this->runRefund(donation: true)->isDonation());
}
public function test_a_payout_is_no_donation(): void
{
$this->assertFalse($this->runRefund()->isDonation());
}
public function test_a_donation_still_gets_a_receipt(): void
{
$this->runRefund(donation: true);
$invoice = Invoice::first();
$this->assertNotNull($invoice->document_filename);
Storage::disk('local')->assertExists($invoice->document_filename);
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
}
public function test_a_donation_settles_the_amount_paid_like_a_payout(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant, donation: true);
// Was einbehalten wurde, bleibt einbehaltener Teilnahmebeitrag; der erstattungsfähige Teil ist
// ab jetzt eine Spende und wird über die Abrechnung geführt, nicht mehr über den Beitrag.
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_the_notice_of_a_donation_names_it(): void
{
$this->runRefund(donation: true);
// Die Schatzmeisterei sieht die Abrechnung ohne den Vorgang dahinter -- warum keine
// Bankverbindung dabei ist, steht nur hier.
$this->assertStringContainsString('Spende statt Rückerstattung', Invoice::first()->comment);
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', Invoice::first()->comment);
}
public function test_contact_details_come_from_the_participant(): void public function test_contact_details_come_from_the_participant(): void
{ {
$this->runRefund(); $this->runRefund();
@@ -374,6 +462,7 @@ class RefundInvoiceTest extends TestCase
accountOwner: 'Mika Muster', accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute(); ))->execute();
$this->assertFalse($response->success); $this->assertFalse($response->success);
@@ -433,4 +522,55 @@ class RefundInvoiceTest extends TestCase
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment); $this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
$this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001); $this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
} }
/*
|--------------------------------------------------------------------------
| Der Verwendungszweck der Überweisung
|--------------------------------------------------------------------------
*/
public function test_the_payment_purpose_of_a_refund_names_the_refund(): void
{
$this->runRefund();
$invoice = Invoice::first();
// Auf dem Kontoauszug des Teilis muss der Vorgang stehen, den es gab: Er hatte keine Auslage,
// er bekommt seinen Beitrag zurück.
$this->assertSame(
'Beitragserstattung Belegnummer ' . $invoice->invoice_number,
$invoice->paymentPurposeText()
);
}
public function test_an_ordinary_invoice_keeps_the_expense_wording(): void
{
$invoice = Invoice::create([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => '2026-0042',
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'contact_name' => 'Mika Muster',
'amount' => 42.0,
]);
$this->assertSame('Auslagenerstattung Belegnummer 2026-0042', $invoice->paymentPurposeText());
}
public function test_a_free_text_purpose_wins(): void
{
$invoice = Invoice::create([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => '2026-0043',
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'contact_name' => 'Mika Muster',
'amount' => 42.0,
'payment_purpose' => 'Sommerlager',
]);
$this->assertSame('Sommerlager', $invoice->paymentPurposeText());
}
} }
+1
View File
@@ -222,6 +222,7 @@ class RefundRetentionTest extends TestCase
accountOwner: 'Mika Muster', accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051', accountIban: 'DE02120300000000202051',
declarationAccepted: true, declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute(); ))->execute();
} }
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace Tests\Feature;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceResponse;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\TravelReason;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\Models\Tenant;
use App\Resources\InvoiceResource;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Reisegrund ist eine Auswahl, kein Freitext mehr.
*
* Gespeichert wird der Schlüssel des Grundes -- außer bei "Anderer Grund": Der Schlüssel `other` sagt
* für sich nichts aus, deshalb steht dort der Text. Dieselbe Spalte führt damit weiter den Freitext der
* Bestandsbelege, ohne dass etwas umgeschrieben werden musste.
*/
class TravelReasonTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
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('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
DB::table('invoice_types')->insert([
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'name' => 'Fahrtkosten',
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
Mail::fake();
}
private function makeCostUnit(): CostUnit
{
return 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,
]);
}
/**
* Der Weg, den eine eingereichte Fahrtkostenabrechnung nimmt. `contactEmail` bleibt leer, damit kein
* Bestätigungsmailversand mitläuft.
*/
private function submit(?string $reason, ?string $note = null, ?string $travellers = 'Mika und Kim'): CreateInvoiceResponse
{
return new CreateInvoiceCommand(new CreateInvoiceRequest(
costUnit: $this->makeCostUnit(),
contactName: 'Max Mustermann',
invoiceType: InvoiceType::INVOICE_TYPE_TRAVELLING,
totalAmount: 42.0,
receiptFile: null,
isDonation: false,
travelRoute: 'Halle Leipzig',
travelReason: $reason,
travelReasonNote: $note,
travellers: $travellers,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Die Auswahl
|--------------------------------------------------------------------------
*/
public function test_the_options_are_ordered_and_only_the_last_one_needs_a_note(): void
{
$options = TravelReason::options();
$this->assertSame(
['event_travel', 'material_transport', 'purchase', 'other'],
array_column($options, 'value')
);
$this->assertSame('An-/Abreise zur Veranstaltung', $options[0]['label']);
$this->assertSame([false, false, false, true], array_column($options, 'requiresNote'));
}
/*
|--------------------------------------------------------------------------
| Was gespeichert wird
|--------------------------------------------------------------------------
*/
public function test_a_fixed_reason_is_stored_as_its_key(): void
{
$invoice = $this->submit(TravelReason::MATERIAL_TRANSPORT)->invoice;
$this->assertSame(TravelReason::MATERIAL_TRANSPORT, $invoice->travel_reason);
}
public function test_the_purpose_carries_the_name_not_the_key(): void
{
// In der Belegliste soll "Materialtransport" stehen, nicht "material_transport".
$invoice = $this->submit(TravelReason::MATERIAL_TRANSPORT)->invoice;
$this->assertSame('Materialtransport — Mika und Kim', $invoice->purpose);
}
public function test_another_reason_stores_the_text_instead_of_the_key(): void
{
$invoice = $this->submit(TravelReason::OTHER, 'Abholung der Ausrüstung')->invoice;
$this->assertSame('Abholung der Ausrüstung', $invoice->travel_reason);
$this->assertSame('Abholung der Ausrüstung — Mika und Kim', $invoice->purpose);
}
public function test_another_reason_without_a_text_creates_nothing(): void
{
// Sicherheitsnetz hinter der Oberfläche: Dort geht es ohne Erläuterung nicht weiter.
$response = $this->submit(TravelReason::OTHER, ' ');
$this->assertFalse($response->success);
$this->assertNull($response->invoice);
$this->assertSame(0, Invoice::count());
$this->assertNotNull($response->message);
}
public function test_an_unknown_value_passes_through_unchanged(): void
{
// Bestandsbelege und ihre Kopien bringen ihren Freitext schon mit.
$invoice = $this->submit('Fahrt zum Landeslager')->invoice;
$this->assertSame('Fahrt zum Landeslager', $invoice->travel_reason);
$this->assertSame('Fahrt zum Landeslager — Mika und Kim', $invoice->purpose);
}
/*
|--------------------------------------------------------------------------
| Was angezeigt wird
|--------------------------------------------------------------------------
*/
public function test_a_key_is_shown_as_its_name(): void
{
$invoice = $this->submit(TravelReason::EVENT_TRAVEL)->invoice;
$this->assertSame('An-/Abreise zur Veranstaltung', $invoice->travelReasonText());
$this->assertSame(
'An-/Abreise zur Veranstaltung',
new InvoiceResource($invoice)->toArray()['travelReason']
);
}
public function test_free_text_is_shown_as_itself(): void
{
$invoice = $this->submit('Fahrt zum Landeslager')->invoice;
$this->assertSame('Fahrt zum Landeslager', $invoice->travelReasonText());
}
}