From e730d6db63cda05a53a205220e3e06ad7026bb39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Sun, 6 Sep 2026 16:48:33 +0200
Subject: [PATCH 1/6] Korrektur Buchungstexte bei Auslagenerstattungen
---
.../CostUnit/Controllers/ExportController.php | 2 +-
app/Models/Invoice.php | 20 ++++++
app/Resources/InvoiceResource.php | 2 +-
tests/Feature/RefundInvoiceTest.php | 61 +++++++++++++++++++
4 files changed, 83 insertions(+), 2 deletions(-)
diff --git a/app/Domains/CostUnit/Controllers/ExportController.php b/app/Domains/CostUnit/Controllers/ExportController.php
index 5d14f4c..d258088 100644
--- a/app/Domains/CostUnit/Controllers/ExportController.php
+++ b/app/Domains/CostUnit/Controllers/ExportController.php
@@ -93,7 +93,7 @@ class ExportController extends CommonController {
'amount' => $invoice->amount,
'recipient_name' => $invoice->contact_bank_owner,
'recipient_iban' => $invoice->contact_bank_iban,
- 'payment_purpose' => $invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $invoice->invoice_number,
+ 'payment_purpose' => $invoice->paymentPurposeText(),
]);
}
}
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
index 2639720..ddfa818 100644
--- a/app/Models/Invoice.php
+++ b/app/Models/Invoice.php
@@ -71,6 +71,26 @@ class Invoice extends InstancedModel
'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;
+ }
+
public function costUnit() : BelongsTo{
return $this->belongsTo(CostUnit::class);
}
diff --git a/app/Resources/InvoiceResource.php b/app/Resources/InvoiceResource.php
index 14b46a1..59810b7 100644
--- a/app/Resources/InvoiceResource.php
+++ b/app/Resources/InvoiceResource.php
@@ -39,7 +39,7 @@ class InvoiceResource {
$returnData['id'] = $this->invoice->id;
$returnData['donation'] = $this->invoice->donation;
$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['accountIban'] = $this->invoice->contact_bank_iban ?? '--';
$returnData['status'] = $this->invoice->status;
diff --git a/tests/Feature/RefundInvoiceTest.php b/tests/Feature/RefundInvoiceTest.php
index 936297b..ceb7f21 100644
--- a/tests/Feature/RefundInvoiceTest.php
+++ b/tests/Feature/RefundInvoiceTest.php
@@ -70,6 +70,16 @@ class RefundInvoiceTest extends TestCase
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
+
+ // 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]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
@@ -433,4 +443,55 @@ class RefundInvoiceTest extends TestCase
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
$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());
+ }
}
--
2.54.0
From b9795f08f0a81830d96de0e95831f6327fbad911 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Sun, 6 Sep 2026 20:11:50 +0200
Subject: [PATCH 2/6] =?UTF-8?q?Besseres=20Handling=20R=C3=BCckerstattungen?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Event/Views/Partials/ParticipantData.vue | 4 +-
.../Event/Views/Partials/ParticipantsList.vue | 16 +-
.../AcceptRefund/AcceptRefundCommand.php | 54 +++-
.../AcceptRefund/AcceptRefundRequest.php | 22 +-
.../CreateRefundDocumentCommand.php | 72 ++++-
.../CreateRefundDocumentRequest.php | 8 +
.../ReleaseRefund/ReleaseRefundCommand.php | 31 +-
.../ReleaseRefund/ReleaseRefundRequest.php | 13 +
.../Controllers/AcceptRefundController.php | 2 +
.../Controllers/RefundDocumentController.php | 6 +-
.../Controllers/RefundPageController.php | 2 +
.../Controllers/ReleaseRefundController.php | 3 +
.../ParticipantRefundTokens.php | 3 +-
.../ParticipantRefund/Views/RefundPage.vue | 274 +++++++++++++++---
.../RefundAcceptedMail.php | 7 +-
app/Models/ParticipantRefund.php | 12 +
app/Resources/ParticipantRefundResource.php | 3 +
..._add_refund_account_and_donation_texts.php | 50 ++++
.../participant-refund-template.sql | 10 +-
.../emails/events/refund_accepted.blade.php | 57 +++-
.../emails/events/refund_released.blade.php | 19 +-
tests/Feature/ParticipantRefundTest.php | 127 ++++++++
tests/Feature/RefundDirectCaptureTest.php | 88 ++++++
tests/Feature/RefundDocumentTest.php | 64 +++-
tests/Feature/RefundInvoiceTest.php | 83 +++++-
tests/Feature/RefundRetentionTest.php | 1 +
26 files changed, 927 insertions(+), 104 deletions(-)
create mode 100644 database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php
diff --git a/app/Domains/Event/Views/Partials/ParticipantData.vue b/app/Domains/Event/Views/Partials/ParticipantData.vue
index 98eed73..eb5b6b6 100644
--- a/app/Domains/Event/Views/Partials/ParticipantData.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantData.vue
@@ -386,7 +386,9 @@ function saveParticipant() {
Bankverbindung des Teilis
- bestätigt am {{ props.participant.refund.acceptedAt }}
+ bestätigt am {{ props.participant.refund.acceptedAt }} – gespendet, keine Auszahlung
diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue
index 5783a92..6bf9f17 100644
--- a/app/Domains/Event/Views/Partials/ParticipantsList.vue
+++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue
@@ -458,6 +458,8 @@ async function execRefund() {
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
+ // Spende: kein Konto, trotzdem sofort eingereicht.
+ donation: refundForm.captureMode === 'donation',
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
retentionReason: hasRetention.value ? refundForm.retentionReason : '',
retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '',
@@ -772,8 +774,8 @@ function mailToGroup(groupKey) {
+
+
+ Der Betrag wird nicht ausgezahlt, sondern als Spende gebucht. Die Erstattung wird sofort
+ eingereicht; der Teili erhält den Beleg per E-Mail.
+
+
@@ -819,6 +830,7 @@ function mailToGroup(groupKey) {
>
Wird gespeichert…
Erstattung einreichen
+ Als Spende einreichen
Erstattung freigeben
diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
index 4e72de9..ef20412 100644
--- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php
@@ -74,14 +74,24 @@ class AcceptRefundCommand
$response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.';
}
- if ($owner === '') {
- $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
- }
+ // 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 ($iban === '') {
- $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
- } elseif (!Iban::isValid($iban)) {
- $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
+ if ($owner === '') {
+ $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.';
+ }
+
+ if ($iban === '') {
+ $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.';
+ } elseif (!Iban::isValid($iban)) {
+ $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.';
+ }
}
if ($response->errorTypes !== []) {
@@ -108,14 +118,21 @@ class AcceptRefundCommand
// Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das
// Einreichen, soll auch kein Beleg gelten.
$document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) {
- $refund->account_owner = $owner;
- $refund->account_iban = $iban;
+ // Bei einer Spende bleiben die Kontofelder leer -- und zwar `null` und nicht Leerstring: Der
+ // 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->status = ParticipantRefund::STATUS_ACCEPTED;
$refund->accepted_at = now();
$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);
@@ -132,7 +149,9 @@ class AcceptRefundCommand
$this->notify($refund, $document);
$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;
}
@@ -177,7 +196,9 @@ class AcceptRefundCommand
invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND,
totalAmount: $refund->amount?->getAmount() ?? 0.0,
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,
contactEmail: $participant->email_1,
contactPhone: $participant->phone_1,
@@ -247,8 +268,15 @@ class AcceptRefundCommand
{
$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(
- 'Rückerstattung Teilnahmebeitrag %s – %s',
+ '%s %s – %s',
+ $subject,
$refund->event->name,
$refund->reasonLabel()
), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid);
diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
index f2daa72..a0ea0f5 100644
--- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundRequest.php
@@ -10,8 +10,28 @@ class AcceptRefundRequest
public readonly ?ParticipantRefund $refund,
public readonly string $accountOwner,
public readonly string $accountIban,
- /** Ob der Teili die Erklärung auf der Seite angekreuzt hat. Ohne sie taugt der Beleg nichts. */
+ /**
+ * 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,
+ /**
+ * 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.
*
diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
index bcb7a4c..6820fda 100644
--- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
@@ -27,6 +27,12 @@ class CreateRefundDocumentCommand
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
+ /** 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 EventParticipant $participant;
@@ -148,12 +154,54 @@ class CreateRefundDocumentCommand
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) !== ''
- ? (string) $text
- : 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
- . 'zurückerstattet bekomme.';
+ // Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
+ // gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
+ return $this->pageText(
+ self::DECLARATION_TEXT,
+ 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
+ . 'zurückerstattet bekomme.'
+ ) . '
' . $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),
'retention_note' => $this->retentionNote(),
+ 'intro_text' => $this->introText(),
'declaration_text' => $this->declarationText(),
'capture_note' => $this->captureNote(),
@@ -272,8 +321,17 @@ class CreateRefundDocumentCommand
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
}
- $rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
- $rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
+ // 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[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
+ }
$html = '';
foreach ($rows as [$key, $value]) {
diff --git a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
index 5c03c8c..6a670fc 100644
--- a/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentRequest.php
@@ -8,6 +8,14 @@ class CreateRefundDocumentRequest
{
public function __construct(
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,
) {
}
}
diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
index 4e95f4f..1cf8d8a 100644
--- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
+++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php
@@ -67,22 +67,24 @@ class ReleaseRefundCommand
'released_at' => now(),
]);
- if ($this->request->hasBankDetails()) {
+ if ($this->request->submitsDirectly()) {
$this->submitDirectly($refund);
}
return $refund;
});
- if (!$this->request->hasBankDetails()) {
+ if (!$this->request->submitsDirectly()) {
$this->notify($refund);
}
$response->success = true;
$response->refund = $refund->fresh();
- $response->message = $this->request->hasBankDetails()
- ? 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.'
- : 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.';
+ $response->message = match (true) {
+ $this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.',
+ $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;
}
@@ -99,6 +101,7 @@ class ReleaseRefundCommand
refund: $refund,
accountOwner: (string) $this->request->accountOwner,
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`
// fest, und der Beleg weist es aus.
capturedBy: currentUser()?->id,
@@ -181,21 +184,29 @@ class ReleaseRefundCommand
*/
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.
- 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.';
}
- if (!$this->request->hasBankDetails()) {
+ if (!$this->request->submitsDirectly()) {
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.';
}
- // Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen. Hier abfangen und nicht erst in der
- // Transaktion, damit die Aktionsleitung eine verständliche Meldung sieht.
+ // Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen -- auch die Spende braucht eine, sie
+ // 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) {
return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.';
}
diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
index 337793e..ad76a59 100644
--- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
+++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundRequest.php
@@ -28,6 +28,13 @@ class ReleaseRefundRequest
*/
public readonly ?string $retentionReason = 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);
}
+ /** 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.
*
diff --git a/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php b/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
index 47f123c..e352510 100644
--- a/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
+++ b/app/Domains/ParticipantRefund/Controllers/AcceptRefundController.php
@@ -22,6 +22,8 @@ class AcceptRefundController extends CommonController
accountOwner: (string) $request->input('accountOwner'),
accountIban: (string) $request->input('accountIban'),
declarationAccepted: $request->boolean('declarationAccepted'),
+ donation: $request->boolean('donation'),
+ accountDeclarationAccepted: $request->boolean('accountDeclarationAccepted'),
);
$response = new AcceptRefundCommand($acceptRequest)->execute();
diff --git a/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php b/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
index 82f7b2c..a31db28 100644
--- a/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
+++ b/app/Domains/ParticipantRefund/Controllers/RefundDocumentController.php
@@ -18,7 +18,11 @@ class RefundDocumentController extends CommonController
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) {
abort(422, $documentResponse->message ?? 'Der Beleg konnte nicht erstellt werden.');
diff --git a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
index 0f91794..031e5c9 100644
--- a/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
+++ b/app/Domains/ParticipantRefund/Controllers/RefundPageController.php
@@ -53,6 +53,8 @@ class RefundPageController extends CommonController
return array_merge($common, [
'state' => 'accepted',
'acceptedAt' => $refund->accepted_at?->format('d.m.Y'),
+ // Wurde gespendet, darf der Abschlusstext keine Überweisung ankündigen, die nicht kommt.
+ 'donation' => $refund->isDonation(),
]);
}
diff --git a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
index 090294c..ba827c2 100644
--- a/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
+++ b/app/Domains/ParticipantRefund/Controllers/ReleaseRefundController.php
@@ -31,6 +31,9 @@ class ReleaseRefundController extends CommonController
// Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
retentionReason: Text::nullIfBlank($request->input('retentionReason')),
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();
diff --git a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
index 59907f2..f7fca5b 100644
--- a/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
+++ b/app/Domains/ParticipantRefund/ParticipantRefundTokens.php
@@ -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.'],
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
'account_iban' => ['description' => 'IBAN', 'sample' => 'DE02 1203 0000 0000 2020 51'],
- 'declaration_text' => ['description' => 'Die Erklärung, die die teilnehmende Person bestätigt hat (gepflegt als Seitentext CONFIRMATION_PARTICIPANT_REFUND)', 'sample' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig zurückerstattet bekomme.'],
+ '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.
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.'],
],
],
diff --git a/app/Domains/ParticipantRefund/Views/RefundPage.vue b/app/Domains/ParticipantRefund/Views/RefundPage.vue
index 1f143f1..2c2abbb 100644
--- a/app/Domains/ParticipantRefund/Views/RefundPage.vue
+++ b/app/Domains/ParticipantRefund/Views/RefundPage.vue
@@ -27,13 +27,33 @@ const props = defineProps({
reason: String,
reasonNote: String,
acceptedAt: String,
+ donation: Boolean,
})
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
-const form = reactive({accountOwner: '', accountIban: '', declarationAccepted: false})
-const errors = reactive({accountOwner: '', accountIban: '', declaration: ''})
+// Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
+// 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)
/**
@@ -48,12 +68,41 @@ const accountComplete = computed(
() => 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() {
- 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.'
+ Object.keys(errors).forEach((key) => (errors[key] = ''))
+
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() {
@@ -61,14 +110,21 @@ async function submit() {
saving.value = true
+ // Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
+ const body = isDonation.value
+ ? {donation: true, declarationAccepted: form.declarationAccepted}
+ : {
+ donation: false,
+ accountOwner: form.accountOwner,
+ accountIban: form.accountIban,
+ declarationAccepted: form.declarationAccepted,
+ accountDeclarationAccepted: form.accountDeclarationAccepted,
+ }
+
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
- body: {
- accountOwner: form.accountOwner,
- accountIban: form.accountIban,
- declarationAccepted: form.declarationAccepted,
- },
+ body,
})
if (!response) {
@@ -88,6 +144,7 @@ async function submit() {
}
toast.success(response.message)
+ donated.value = isDonation.value
state.value = 'accepted'
} finally {
saving.value = false
@@ -128,7 +185,12 @@ async function submit() {
-
+
+ Du hast den Betrag gespendet (am {{ props.acceptedAt }})
+ – vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
+ bitte an die Aktionsleitung: {{ props.eventEmail }}
+
+
Deine Angaben liegen uns vor (seit {{ props.acceptedAt }}).
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
@@ -141,57 +203,161 @@ async function submit() {
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
-
Auf welches Konto sollen wir überweisen?
-
@@ -235,6 +401,34 @@ h3 {
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 {
margin-bottom: 16px;
}
diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
index e5fe6d9..ea81bde 100644
--- a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
+++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php
@@ -31,7 +31,9 @@ class RefundAcceptedMail extends Mailable
{
return new Envelope(
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
),
);
@@ -54,6 +56,9 @@ class RefundAcceptedMail extends Mailable
'accountIban' => Iban::format((string) $this->refund->account_iban),
'hasDocument' => $this->pdfContent !== null,
'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.
'hasRetention' => $this->refund->hasRetention(),
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php
index afbe8e5..a9118ac 100644
--- a/app/Models/ParticipantRefund.php
+++ b/app/Models/ParticipantRefund.php
@@ -117,6 +117,18 @@ class ParticipantRefund extends InstancedModel
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
{
return $this->status === self::STATUS_PENDING;
diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php
index b732628..8bfd936 100644
--- a/app/Resources/ParticipantRefundResource.php
+++ b/app/Resources/ParticipantRefundResource.php
@@ -40,6 +40,9 @@ class ParticipantRefundResource extends JsonResource
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
// Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand.
'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number,
+ // Ob überhaupt ausgezahlt wird: Bei einer Spende bleibt der Betrag beim Verband. Steht wie
+ // die Belegnummer in der Abrechnung.
+ 'donation' => $this->resource->isDonation(),
];
}
}
diff --git a/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php b/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php
new file mode 100644
index 0000000..7239f8c
--- /dev/null
+++ b/database/migrations/2026_09_10_140010_add_refund_account_and_donation_texts.php
@@ -0,0 +1,50 @@
+ */
+ 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();
+ }
+};
diff --git a/database/seed-scripts/participant-refund-template.sql b/database/seed-scripts/participant-refund-template.sql
index 1e0a4e1..2023f8b 100644
--- a/database/seed-scripts/participant-refund-template.sql
+++ b/database/seed-scripts/participant-refund-template.sql
@@ -16,8 +16,12 @@
-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht,
-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil).
--
--- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili
--- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text.
+-- Der Erklärungssatz kommt aus den Seitentexten CONFIRMATION_PARTICIPANT_REFUND(_ACCOUNT bzw. _DONATION),
+-- 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';
@@ -129,7 +133,7 @@ body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1
+ die Aktionsleitung hat vermerkt, dass du auf die Rückerstattung deines Teilnahmebeitrags zur
+ Veranstaltung "{{$eventTitle}}" verzichtest und den Betrag spendest.
+ Du musst nichts weiter tun.
+
+
+ Bitte prüfe die unten stehenden Angaben. Stimmt etwas nicht, melde dich
+ bitte umgehend bei der Aktionsleitung.
+
+ @else
+
+ vielen Dank – du hast auf die Rückerstattung deines Teilnahmebeitrags zur Veranstaltung
+ "{{$eventTitle}}" verzichtet und den Betrag gespendet.
+ Du musst nichts weiter tun.
+
+ @endif
+@elseif ($capturedByManagement)
die Aktionsleitung hat deine Bankverbindung für die Rückerstattung deines Teilnahmebeitrags zur
Veranstaltung "{{$eventTitle}}" erfasst. Du musst nichts weiter tun: Die
@@ -34,14 +52,21 @@
- 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.
-
+@if ($donation)
+
+ Vielen Dank, dass du uns den Betrag überlässt – er kommt unserer Arbeit zugute.
+
+@else
+
+ 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.
+
+@endif
@include('emails.subparts.disclaimer')
diff --git a/resources/views/emails/events/refund_released.blade.php b/resources/views/emails/events/refund_released.blade.php
index 7abcf04..806296f 100644
--- a/resources/views/emails/events/refund_released.blade.php
+++ b/resources/views/emails/events/refund_released.blade.php
@@ -29,8 +29,19 @@
folgenden Link ein:
+
+ Wichtig: Wir dürfen nur auf das Konto zurückzahlen, von dem der Teilnahmebeitrag
+ gezahlt wurde. Wurde er von einem anderen Konto überwiesen – etwa dem eines Elternteils –,
+ gib bitte dieses an.
+
+
- Bankverbindung eintragen
+ Du kannst den Betrag stattdessen auch spenden. Dann brauchen wir keine
+ Bankverbindung von dir; die Auswahl findest du hinter demselben Link.
+
- Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag
- selbst steht fest und lässt sich über den Link nicht ändern – wenn du dazu Fragen hast, wende
- dich bitte an die Aktionsleitung.
+ Solange uns deine Bankverbindung nicht vorliegt und du dich auch nicht für die Spende entschieden
+ hast, können wir den Vorgang nicht abschließen. Der Betrag selbst steht fest und lässt sich über den
+ Link nicht ändern – wenn du dazu Fragen hast, wende dich bitte an die Aktionsleitung.
diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php
index ccf4de3..a568d80 100644
--- a/tests/Feature/ParticipantRefundTest.php
+++ b/tests/Feature/ParticipantRefundTest.php
@@ -232,12 +232,26 @@ class ParticipantRefundTest extends TestCase
string $owner = 'Mika Muster',
string $iban = 'DE02120300000000202051',
bool $declarationAccepted = true,
+ bool $accountDeclarationAccepted = true,
) {
return new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: $owner,
accountIban: $iban,
declarationAccepted: $declarationAccepted,
+ accountDeclarationAccepted: $accountDeclarationAccepted,
+ ))->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();
}
@@ -459,6 +473,105 @@ class ParticipantRefundTest extends TestCase
$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
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -473,6 +586,19 @@ class ParticipantRefundTest extends TestCase
->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
{
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
@@ -716,6 +842,7 @@ class ParticipantRefundTest extends TestCase
'accountOwner' => 'Mika Muster',
'accountIban' => 'DE02 1203 0000 0000 2020 51',
'declarationAccepted' => true,
+ 'accountDeclarationAccepted' => true,
])->assertOk()->assertJsonPath('status', 'success');
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
diff --git a/tests/Feature/RefundDirectCaptureTest.php b/tests/Feature/RefundDirectCaptureTest.php
index b086f48..8a9be14 100644
--- a/tests/Feature/RefundDirectCaptureTest.php
+++ b/tests/Feature/RefundDirectCaptureTest.php
@@ -412,6 +412,94 @@ class RefundDirectCaptureTest extends TestCase
$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
{
$participant = $this->makeParticipant();
diff --git a/tests/Feature/RefundDocumentTest.php b/tests/Feature/RefundDocumentTest.php
index f399337..5cfb32d 100644
--- a/tests/Feature/RefundDocumentTest.php
+++ b/tests/Feature/RefundDocumentTest.php
@@ -98,7 +98,8 @@ class RefundDocumentTest extends TestCase
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
- 'content' => '{details_table}
diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
index 987f089..824666a 100644
--- a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
+++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
@@ -148,7 +148,7 @@ class CreateIncomeSurplusStatementCommand
$rows[] = [
'number' => (string) $invoice->invoice_number,
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
- 'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
+ 'purpose' => $this->purpose($invoice->purposeText(), $invoice->comment),
'amount' => Amount::fromString($invoice->amount),
];
}
@@ -166,18 +166,21 @@ class CreateIncomeSurplusStatementCommand
}
/**
- * Wofür der Beleg steht.
+ * Wofür der Beleg steht, um die Anmerkung ergänzt.
*
- * `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.
+ * Den Zweck selbst bestimmt {@see \App\Models\Invoice::purposeText()} -- dieselbe Ermittlung wie in
+ * der Beleg-Übersicht, damit ein Beleg nicht an zwei Stellen Verschiedenes über sich behauptet. Die
+ * Anmerkung kommt nur hier dazu: Auf der Aufstellung steht der Beleg für sich, ohne die Detailansicht
+ * daneben.
+ *
+ * Ältere Belege haben keinen Zweck erfasst -- 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
+ private function purpose(?string $purpose, ?string $comment): string
{
$parts = [];
- foreach ([$typeOther, $comment] as $part) {
+ foreach ([$purpose, $comment] as $part) {
if (trim((string) $part) !== '') {
$parts[] = trim((string) $part);
}
diff --git a/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue b/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
index 43c3481..8deae6e 100644
--- a/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
+++ b/app/Domains/Invoice/Views/Partials/myInvoiceDetails/ListInvoices.vue
@@ -45,12 +45,13 @@
-
{{props.data.title}}
+
{{props.data.title}}
{{invoice.invoiceNumber}}
-
{{invoice.invoiceType}}
+
{{invoice.invoiceTypeShort}}
+
{{invoice.purpose}}
{{invoice.amount}}
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
index ddfa818..48b5c53 100644
--- a/app/Models/Invoice.php
+++ b/app/Models/Invoice.php
@@ -91,6 +91,27 @@ class Invoice extends InstancedModel
return $subject . ' Belegnummer ' . $this->invoice_number;
}
+ /**
+ * Wofür der Beleg steht -- der "Zahlungsgrund" der Beleglisten und der "Zweck" der EüR-Anlage.
+ *
+ * `type_other` ("Was wurde eingekauft") trägt seit der Pflichtangabe zu jeder Abrechnung den Zweck.
+ * Bei Fahrtkosten bleibt das Feld leer: dort schreibt der Einreiche-Flow die Strecke nach
+ * `travel_direction`. Den Zweck trägt dann der Reisegrund, ergänzt um den Namen der reisenden
+ * Person -- bei einer Fahrt ist "wer" Teil der Begründung, nicht bloß Kontaktangabe.
+ *
+ * Ältere Belege und Beitragserstattungen haben nichts davon gesetzt; dann bleibt der Text leer.
+ * Ein "--" würde in einer Belegliste nur Platz kosten.
+ */
+ public function purposeText() : string {
+ $parts = $this->type === InvoiceType::INVOICE_TYPE_TRAVELLING
+ ? [$this->travel_reason, $this->contact_name]
+ : [$this->type_other];
+
+ $parts = array_map(fn ($part) => trim((string) $part), $parts);
+
+ return implode(' — ', array_filter($parts, fn ($part) => $part !== ''));
+ }
+
public function costUnit() : BelongsTo{
return $this->belongsTo(CostUnit::class);
}
diff --git a/app/Resources/InvoiceResource.php b/app/Resources/InvoiceResource.php
index 59810b7..db61dc7 100644
--- a/app/Resources/InvoiceResource.php
+++ b/app/Resources/InvoiceResource.php
@@ -30,6 +30,7 @@ class InvoiceResource {
}
$returnData['invoiceTypeShort'] = $this->invoice->invoiceType()->name;
+ $returnData['purpose'] = $this->invoice->purposeText();
$returnData['costUnitName'] = $this->invoice->costUnit()->first()->name;
$returnData['invoiceNumber'] = $this->invoice->invoice_number;
$returnData['contactName'] = $this->invoice->contact_name;
diff --git a/tests/Feature/EventIncomeSurplusStatementTest.php b/tests/Feature/EventIncomeSurplusStatementTest.php
index 033b639..ceb7a36 100644
--- a/tests/Feature/EventIncomeSurplusStatementTest.php
+++ b/tests/Feature/EventIncomeSurplusStatementTest.php
@@ -263,6 +263,32 @@ class EventIncomeSurplusStatementTest extends TestCase
$this->assertSame('Bastelmaterial — Materialkauf', $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 — Materialkauf', $this->group('Fahrtkosten')['rows'][0]['purpose']);
+ }
+
public function test_an_older_receipt_without_the_purchase_note_falls_back_to_the_comment(): void
{
// Belege von vor der Pflichtangabe haben `type_other` leer.
@@ -471,7 +497,8 @@ class EventIncomeSurplusStatementTest extends TestCase
float $amount,
string $status,
bool $donation = false,
- ?string $typeOther = null
+ ?string $typeOther = null,
+ ?string $travelReason = null
): Invoice {
return Invoice::create([
'tenant' => $this->tenant->slug,
@@ -480,6 +507,7 @@ class EventIncomeSurplusStatementTest extends TestCase
'status' => $status,
'type' => $type,
'type_other' => $typeOther,
+ 'travel_reason' => $travelReason,
'donation' => $donation,
'contact_name' => 'Mika Muster',
'comment' => 'Materialkauf',
diff --git a/tests/Feature/InvoicePurposeTest.php b/tests/Feature/InvoicePurposeTest.php
new file mode 100644
index 0000000..2951a3e
--- /dev/null
+++ b/tests/Feature/InvoicePurposeTest.php
@@ -0,0 +1,133 @@
+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]);
+
+ // 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,
+ ]);
+ }
+ }
+
+ 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 purposeOf(array $attributes): string
+ {
+ $this->sequence++;
+
+ $invoice = 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));
+
+ return new InvoiceResource($invoice)->toArray()['purpose'];
+ }
+
+ 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`.
+ $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,
+ ]));
+ }
+}
--
2.54.0
From 40d5634764ebba6edb415fb0d67e9f3ade954e8a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Mon, 7 Sep 2026 14:17:50 +0200
Subject: [PATCH 5/6] Zahlungszwecke
---
.../CreateIncomeSurplusStatementCommand.php | 28 +--
.../CreateInvoice/CreateInvoiceCommand.php | 78 ++++++-
.../CreateInvoice/CreateInvoiceRequest.php | 22 +-
.../CreateInvoice/CreateInvoiceResponse.php | 4 +
.../CreateInvoiceReceiptCommand.php | 11 +-
.../UpdateInvoice/UpdateInvoiceCommand.php | 11 +
.../UpdateInvoice/UpdateInvoiceRequest.php | 4 +-
.../Invoice/Controllers/EditController.php | 74 +++---
.../Controllers/SaveInvoiceController.php | 75 +++----
.../invoiceDetails/DistanceAllowance.vue | 9 -
.../Partials/invoiceDetails/EditInvoice.vue | 16 +-
.../Views/Partials/invoiceDetails/Header.vue | 4 +
.../myInvoiceDetails/DistanceAllowance.vue | 9 -
.../Partials/newInvoice/payment-data.vue | 4 -
.../Views/Partials/newInvoice/refund-data.vue | 8 +-
.../newInvoice/travel-expense-accounting.vue | 91 +++++---
.../AcceptRefund/AcceptRefundCommand.php | 9 -
app/Enumerations/TravelReason.php | 72 ++++++
app/Models/Invoice.php | 55 ++++-
app/Providers/GlobalDataProvider.php | 6 +
app/Resources/InvoiceResource.php | 6 +-
...6_09_11_140010_add_purpose_to_invoices.php | 25 +++
...026_09_12_140010_create_travel_reasons.php | 70 ++++++
routes/web.php | 1 +
.../EventIncomeSurplusStatementTest.php | 35 ++-
tests/Feature/InvoicePurposeTest.php | 212 +++++++++++++++++-
tests/Feature/TravelReasonTest.php | 193 ++++++++++++++++
27 files changed, 927 insertions(+), 205 deletions(-)
create mode 100644 app/Enumerations/TravelReason.php
create mode 100644 database/migrations/2026_09_11_140010_add_purpose_to_invoices.php
create mode 100644 database/migrations/2026_09_12_140010_create_travel_reasons.php
create mode 100644 tests/Feature/TravelReasonTest.php
diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
index 824666a..4d9d49c 100644
--- a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
+++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
@@ -148,7 +148,9 @@ class CreateIncomeSurplusStatementCommand
$rows[] = [
'number' => (string) $invoice->invoice_number,
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
- 'purpose' => $this->purpose($invoice->purposeText(), $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),
];
}
@@ -165,30 +167,6 @@ class CreateIncomeSurplusStatementCommand
return ['groups' => $groups, 'total' => $total];
}
- /**
- * Wofür der Beleg steht, um die Anmerkung ergänzt.
- *
- * Den Zweck selbst bestimmt {@see \App\Models\Invoice::purposeText()} -- dieselbe Ermittlung wie in
- * der Beleg-Übersicht, damit ein Beleg nicht an zwei Stellen Verschiedenes über sich behauptet. Die
- * Anmerkung kommt nur hier dazu: Auf der Aufstellung steht der Beleg für sich, ohne die Detailansicht
- * daneben.
- *
- * Ältere Belege haben keinen Zweck erfasst -- 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 $purpose, ?string $comment): string
- {
- $parts = [];
-
- foreach ([$purpose, $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.
*
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
index 4bd479d..a3cf03e 100644
--- a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
@@ -3,6 +3,8 @@
namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Enumerations\InvoiceStatus;
+use App\Enumerations\InvoiceType;
+use App\Enumerations\TravelReason;
use App\Mail\InvoiceMails\InvoiceMailsNewInvoiceMail;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
@@ -19,10 +21,19 @@ class CreateInvoiceCommand {
public function execute() : CreateInvoiceResponse {
$response = new CreateInvoiceResponse();
+ $rejection = $this->rejectTravelReason();
+ if ($rejection !== null) {
+ $response->message = $rejection;
+
+ return $response;
+ }
+
if ($this->request->accountIban === 'undefined') {
$this->request->accountIban = null;
}
+ $travelReason = $this->travelReason();
+
$invoice = Invoice::create([
'tenant' => currentTenant()->slug,
'cost_unit_id' => $this->request->costUnit->id,
@@ -30,6 +41,7 @@ class CreateInvoiceCommand {
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => $this->request->invoiceType,
'type_other' => $this->request->invoiceTypeExtended,
+ 'purpose' => $this->purpose($travelReason),
'donation' => $this->request->isDonation,
'user_id' => $this->request->paymentPurpose === null ? $this->request->userId : null,
'contact_name' => $this->request->contactName,
@@ -40,9 +52,7 @@ class CreateInvoiceCommand {
'amount' => $this->request->totalAmount,
'distance' => $this->request->distance,
'travel_direction' => $this->request->travelRoute,
- 'travel_reason' => $this->request->travelReason,
- 'passengers' => $this->request->passengers,
- 'transportation' => $this->request->transportations,
+ 'travel_reason' => $travelReason,
'payment_purpose' => $this->request->paymentPurpose,
'comment' => $this->request->notices,
'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 {
$lastInvoiceNumber = Invoice::query()
->where('tenant', currentTenant()->slug)
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
index bbe9593..57392b5 100644
--- a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
@@ -16,16 +16,24 @@ class CreateInvoiceRequest {
public ?string $invoiceTypeExtended;
public ?string $travelRoute;
public ?int $distance;
- public ?int $passengers;
- public ?int $transportations;
public ?InvoiceFile $receiptFile;
public float $totalAmount;
public bool $isDonation;
public ?int $userId;
+ /** Der Slug eines Reisegrundes -- oder ein Freitext, wenn er von einem Bestandsbeleg stammt. */
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 $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(
CostUnit $costUnit,
@@ -42,11 +50,12 @@ class CreateInvoiceRequest {
?string $invoiceTypeExtended = null,
?string $travelRoute = null,
?int $distance = null,
- ?int $passengers = null,
- ?int $transportations,
?string $travelReason = null,
+ ?string $travelReasonNote = null,
?string $paymentPurpose = null,
?string $notices = null,
+ ?string $travellers = null,
+ ?string $purpose = null,
) {
$this->costUnit = $costUnit;
@@ -55,8 +64,6 @@ class CreateInvoiceRequest {
$this->invoiceTypeExtended = $invoiceTypeExtended;
$this->travelRoute = $travelRoute;
$this->distance = $distance;
- $this->passengers = $passengers;
- $this->transportations = $transportations;
$this->receiptFile = $receiptFile;
$this->contactEmail = $contactEmail;
$this->contactPhone = $contactPhone;
@@ -66,8 +73,11 @@ class CreateInvoiceRequest {
$this->isDonation = $isDonation;
$this->userId = $userId;
$this->travelReason = $travelReason;
+ $this->travelReasonNote = $travelReasonNote;
$this->paymentPurpose = $paymentPurpose;
$this->notices = $notices;
+ $this->travellers = $travellers;
+ $this->purpose = $purpose;
if ($accountIban === 'undefined') {
$this->accountIban = null;
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
index 357fed9..eeb6f9b 100644
--- a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
@@ -8,8 +8,12 @@ class CreateInvoiceResponse {
public bool $success;
public ?Invoice $invoice;
+ /** Warum keine Abrechnung entstanden ist -- für die Rückmeldung an die einreichende Person. */
+ public ?string $message;
+
public function __construct() {
$this->success = false;
$this->invoice = null;
+ $this->message = null;
}
}
diff --git a/app/Domains/Invoice/Actions/CreateInvoiceReceipt/CreateInvoiceReceiptCommand.php b/app/Domains/Invoice/Actions/CreateInvoiceReceipt/CreateInvoiceReceiptCommand.php
index ffe696c..eb3c840 100644
--- a/app/Domains/Invoice/Actions/CreateInvoiceReceipt/CreateInvoiceReceiptCommand.php
+++ b/app/Domains/Invoice/Actions/CreateInvoiceReceipt/CreateInvoiceReceiptCommand.php
@@ -83,10 +83,8 @@ class CreateInvoiceReceiptCommand {
$travelPartTemplate = <<
Reiseweg:
%1\$s
-
Grund der Reise:
%6\$s
+
Grund der Reise:
%4\$s
Gesamtlänge der Strecke:
%2\$s km x %3\$s / km
-
Materialtransport:
%4\$s
-
Mitfahrende im PKW:
%5\$s
HTML;
$flatTravelPart = sprintf(
@@ -94,8 +92,6 @@ HTML;
$invoiceReadable['travelDirection'] ,
$invoiceReadable['distance'],
$invoiceReadable['distanceAllowance'],
- $invoiceReadable['transportation'],
- $invoiceReadable['passengers'],
$invoiceReadable['travelReason'] ,
);
@@ -180,7 +176,10 @@ HTML;
$invoiceReadable['contactEmail'],
$invoiceReadable['contactPhone'],
$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'],
$paymentType,
$invoiceReadable['amount'],
diff --git a/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceCommand.php b/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceCommand.php
index d066221..47e17c3 100644
--- a/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceCommand.php
+++ b/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceCommand.php
@@ -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 ?? '--') . '. ';
+ $this->request->invoice->purpose = $purpose;
+ }
+
$this->request->invoice->comment = $this->request->comment;
$this->request->invoice->changes = $changes;
diff --git a/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceRequest.php b/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceRequest.php
index 571ecfa..4a42ef7 100644
--- a/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceRequest.php
+++ b/app/Domains/Invoice/Actions/UpdateInvoice/UpdateInvoiceRequest.php
@@ -13,9 +13,11 @@ class UpdateInvoiceRequest {
public CostUnit $costUnit;
public Invoice $invoice;
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->purpose = $purpose;
$this->invoiceType = $invoiceType;
$this->costUnit = $costUnit;
$this->invoice = $invoice;
diff --git a/app/Domains/Invoice/Controllers/EditController.php b/app/Domains/Invoice/Controllers/EditController.php
index 3dbec7b..38aca00 100644
--- a/app/Domains/Invoice/Controllers/EditController.php
+++ b/app/Domains/Invoice/Controllers/EditController.php
@@ -32,25 +32,26 @@ class EditController extends CommonController{
$receiptfile->fullPath = $invoice->document_filename;
}
$createInvoiceRequest = new CreateInvoiceRequest(
- $invoice->costUnit()->first(),
- $invoice->contact_name,
- $invoice->type,
- $invoice->amount,
- $receiptfile,
- $invoice->donation,
- $invoice->user_id,
- $invoice->contact_email,
- $invoice->contact_phone,
- $invoice->contact_bank_owner,
- $invoice->contact_bank_iban,
- $invoice->type_other,
- $invoice->travel_direction,
- $invoice->distance,
- $invoice->passengers,
- $invoice->transportation,
- $invoice->travel_reason,
- $invoice->payment_purpose,
- $invoice->comment,
+ costUnit: $invoice->costUnit()->first(),
+ contactName: $invoice->contact_name,
+ invoiceType: $invoice->type,
+ totalAmount: $invoice->amount,
+ receiptFile: $receiptfile,
+ isDonation: $invoice->donation,
+ userId: $invoice->user_id,
+ contactEmail: $invoice->contact_email,
+ contactPhone: $invoice->contact_phone,
+ accountOwner: $invoice->contact_bank_owner,
+ accountIban: $invoice->contact_bank_iban,
+ invoiceTypeExtended: $invoice->type_other,
+ travelRoute: $invoice->travel_direction,
+ distance: $invoice->distance,
+ travelReason: $invoice->travel_reason,
+ paymentPurpose: $invoice->payment_purpose,
+ notices: $invoice->comment,
+ // Die rohe Spalte, nicht purposeText(): Ein Beleg, der seinen Zahlungsgrund bisher ableitet,
+ // soll das als Kopie weiter tun.
+ purpose: $invoice->purpose,
);
$invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest);
@@ -92,7 +93,8 @@ class EditController extends CommonController{
$modifyData['notices'],
$invoiceType,
$newCostUnit,
- $newAmount
+ $newAmount,
+ $modifyData['purpose'] ?? null
);
$updateInvoiceCommand = new UpdateInvoiceCommand($updateInvoiceRequest);
$updateInvoiceCommand->execute();
@@ -107,22 +109,22 @@ class EditController extends CommonController{
$receiptfile->fullPath = $invoice->document_filename;
}
$createInvoiceRequest = new CreateInvoiceRequest(
- $invoice->costUnit()->first(),
- $invoice->contact_name,
- $invoice->type,
- $amountLeft->getAmount(),
- $receiptfile,
- $invoice->donation,
- $invoice->user_id,
- $invoice->contact_email,
- $invoice->contact_phone,
- $invoice->contact_bank_owner,
- $invoice->contact_bank_iban,
- $invoice->type_other,
- $invoice->travel_direction,
- $invoice->distance,
- $invoice->passengers,
- $invoice->transportation
+ costUnit: $invoice->costUnit()->first(),
+ contactName: $invoice->contact_name,
+ invoiceType: $invoice->type,
+ totalAmount: $amountLeft->getAmount(),
+ receiptFile: $receiptfile,
+ isDonation: $invoice->donation,
+ userId: $invoice->user_id,
+ contactEmail: $invoice->contact_email,
+ contactPhone: $invoice->contact_phone,
+ accountOwner: $invoice->contact_bank_owner,
+ accountIban: $invoice->contact_bank_iban,
+ invoiceTypeExtended: $invoice->type_other,
+ travelRoute: $invoice->travel_direction,
+ distance: $invoice->distance,
+ travelReason: $invoice->travel_reason,
+ purpose: $invoice->purpose,
);
$invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest);
diff --git a/app/Domains/Invoice/Controllers/SaveInvoiceController.php b/app/Domains/Invoice/Controllers/SaveInvoiceController.php
index 0c8f370..649958d 100644
--- a/app/Domains/Invoice/Controllers/SaveInvoiceController.php
+++ b/app/Domains/Invoice/Controllers/SaveInvoiceController.php
@@ -66,50 +66,43 @@ class SaveInvoiceController extends CommonController
}
$createInvoiceRequest = new CreateInvoiceRequest(
- $costUnit,
- $request->input('name'),
- InvoiceType::INVOICE_TYPE_TRAVELLING,
- $amount,
- $uploadedFile,
- 'donation' === $request->input('decision') ? true : false,
- $this->users->getCurrentUserDetails()['userId'],
- $request->input('email'),
- $request->input('telephone'),
- $request->input('accountOwner'),
- $request->input('accountIban'),
- null,
- $request->input('otherText'),
- $distance,
- $request->input('havePassengers'),
- $request->input('materialTransportation'),
- $request->input('travelReason'),
- null,
- $notices
+ costUnit: $costUnit,
+ contactName: $request->input('name'),
+ invoiceType: InvoiceType::INVOICE_TYPE_TRAVELLING,
+ totalAmount: $amount,
+ receiptFile: $uploadedFile,
+ isDonation: 'donation' === $request->input('decision') ? true : false,
+ userId: $this->users->getCurrentUserDetails()['userId'],
+ contactEmail: $request->input('email'),
+ contactPhone: $request->input('telephone'),
+ accountOwner: $request->input('accountOwner'),
+ accountIban: $request->input('accountIban'),
+ travelRoute: $request->input('otherText'),
+ distance: $distance,
+ travelReason: $request->input('travelReason'),
+ travelReasonNote: $request->input('travelReasonNote'),
+ notices: $notices,
+ travellers: $request->input('travellers')
);
break;
default:
$createInvoiceRequest = new CreateInvoiceRequest(
- $costUnit,
- $request->input('name'),
- $invoiceType,
- Amount::fromString($request->input('amount'))->getAmount(),
- $uploadedFile,
- 'donation' === $request->input('decision') ? true : false,
- $this->users->getCurrentUserDetails()['userId'],
- $request->input('email'),
- $request->input('telephone'),
- $request->input('accountOwner'),
- $request->input('accountIban'),
- $request->input('otherText'),
- null,
- null,
- $request->input('havePassengers'),
- $request->input('materialTransportation'),
- null,
- $paymentPurpose,
- $notices
+ costUnit: $costUnit,
+ contactName: $request->input('name'),
+ invoiceType: $invoiceType,
+ totalAmount: Amount::fromString($request->input('amount'))->getAmount(),
+ receiptFile: $uploadedFile,
+ isDonation: 'donation' === $request->input('decision') ? true : false,
+ userId: $this->users->getCurrentUserDetails()['userId'],
+ contactEmail: $request->input('email'),
+ contactPhone: $request->input('telephone'),
+ accountOwner: $request->input('accountOwner'),
+ accountIban: $request->input('accountIban'),
+ invoiceTypeExtended: $request->input('otherText'),
+ paymentPurpose: $paymentPurpose,
+ notices: $notices
);
break;
@@ -128,5 +121,11 @@ class SaveInvoiceController extends CommonController
'message' => 'Alright'
]);
}
+
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $response->message
+ ?? 'Beim Speichern ist ein Fehler aufgetreten. Bitte starte den Vorgang erneut.'
+ ]);
}
}
diff --git a/app/Domains/Invoice/Views/Partials/invoiceDetails/DistanceAllowance.vue b/app/Domains/Invoice/Views/Partials/invoiceDetails/DistanceAllowance.vue
index 7ca8eff..28bfd99 100644
--- a/app/Domains/Invoice/Views/Partials/invoiceDetails/DistanceAllowance.vue
+++ b/app/Domains/Invoice/Views/Partials/invoiceDetails/DistanceAllowance.vue
@@ -37,15 +37,6 @@ const props = defineProps({
{{props.invoice.amount}}
-
-
Marterialtransport
-
{{props.invoice.transportation}}
-
-
-
-
Hat Personen mitgenommen
-
{{props.invoice.passengers}}
-
diff --git a/app/Domains/Invoice/Views/Partials/invoiceDetails/EditInvoice.vue b/app/Domains/Invoice/Views/Partials/invoiceDetails/EditInvoice.vue
index 693ff09..de49093 100644
--- a/app/Domains/Invoice/Views/Partials/invoiceDetails/EditInvoice.vue
+++ b/app/Domains/Invoice/Views/Partials/invoiceDetails/EditInvoice.vue
@@ -16,11 +16,18 @@ const props = defineProps({
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({
type_internal: props.newInvoice.internalType || '',
cost_unit: props.newInvoice.costUnitId || '',
amount: props.newInvoice.amountPlain || '',
- notices: props.newInvoice.comments || '',
+ purpose: props.newInvoice.purpose || '',
+ notices: !existingComment || existingComment === '--' ? '' : existingComment,
})
const submitForm = () => {
@@ -70,6 +77,13 @@ onMounted(async () => {