Zahlungszwecke

This commit is contained in:
2026-09-07 14:17:50 +02:00
parent 350c8dd0d0
commit 40d5634764
27 changed files with 927 additions and 205 deletions
@@ -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->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), 'amount' => Amount::fromString($invoice->amount),
]; ];
} }
@@ -165,30 +167,6 @@ class CreateIncomeSurplusStatementCommand
return ['groups' => $groups, 'total' => $total]; 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. * Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
* *
@@ -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>
@@ -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
type="text"
class="width-full"
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 <input
type="text" type="text"
name="travel-reason" name="travellers"
v-model="travelReason" 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"
/> />
@@ -207,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,
+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();
}
}
+44 -11
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',
@@ -94,19 +104,42 @@ class Invoice extends InstancedModel
/** /**
* Wofür der Beleg steht -- der "Zahlungsgrund" der Beleglisten und der "Zweck" der EüR-Anlage. * 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. * Erfasst wird er beim Anlegen und steht danach in der eigenen Spalte: eine Angabe, keine Ableitung.
* Bei Fahrtkosten bleibt das Feld leer: dort schreibt der Einreiche-Flow die Strecke nach * Nur so lässt er sich korrigieren, ohne dass ihn der nächste Vorgang wieder überschreibt.
* `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. * Bestandsbelege haben die Spalte leer -- für sie wird weiter abgeleitet, bei Fahrtkosten aus
* Ein "--" würde in einer Belegliste nur Platz kosten. * 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 { public function purposeText() : string {
$parts = $this->type === InvoiceType::INVOICE_TYPE_TRAVELLING if (trim((string) $this->purpose) !== '') {
? [$this->travel_reason, $this->contact_name] return $this->purpose;
: [$this->type_other]; }
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); $parts = array_map(fn ($part) => trim((string) $part), $parts);
return implode(' — ', array_filter($parts, fn ($part) => $part !== '')); return implode(' — ', array_filter($parts, fn ($part) => $part !== ''));
+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(
[ [
+3 -3
View File
@@ -50,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();
@@ -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');
}
};
+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,7 +260,26 @@ 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_a_recorded_purpose_wins(): void
{
// 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 public function test_travel_costs_name_the_reason_and_who_travelled(): void
@@ -286,16 +305,18 @@ class EventIncomeSurplusStatementTest extends TestCase
travelReason: 'Landeslager' travelReason: 'Landeslager'
); );
$this->assertSame('Landeslager — Mika Muster — Materialkauf', $this->group('Fahrtkosten')['rows'][0]['purpose']); $this->assertSame('Landeslager — Mika Muster', $this->group('Fahrtkosten')['rows'][0]['purpose']);
} }
public function test_an_older_receipt_without_the_purchase_note_falls_back_to_the_comment(): void public function test_the_comment_is_no_longer_part_of_the_purpose(): void
{ {
// Belege von vor der Pflichtangabe haben `type_other` leer. // 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']);
} }
/* /*
@@ -498,7 +519,8 @@ class EventIncomeSurplusStatementTest extends TestCase
string $status, string $status,
bool $donation = false, bool $donation = false,
?string $typeOther = null, ?string $typeOther = null,
?string $travelReason = null ?string $travelReason = null,
?string $purpose = null
): Invoice { ): Invoice {
return Invoice::create([ return Invoice::create([
'tenant' => $this->tenant->slug, 'tenant' => $this->tenant->slug,
@@ -507,6 +529,7 @@ class EventIncomeSurplusStatementTest extends TestCase
'status' => $status, 'status' => $status,
'type' => $type, 'type' => $type,
'type_other' => $typeOther, 'type_other' => $typeOther,
'purpose' => $purpose,
'travel_reason' => $travelReason, 'travel_reason' => $travelReason,
'donation' => $donation, 'donation' => $donation,
'contact_name' => 'Mika Muster', 'contact_name' => 'Mika Muster',
+204 -8
View File
@@ -2,20 +2,30 @@
namespace Tests\Feature; 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\CostUnitType;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType; use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole;
use App\Models\CostUnit; use App\Models\CostUnit;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Resources\InvoiceResource; use App\Resources\InvoiceResource;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase; use Tests\TestCase;
/** /**
* Die Spalte "Zahlungsgrund" der Beleglisten. Sie liest nicht ein Feld, sondern das, was der * Der Zahlungsgrund: die gleichnamige Spalte der Beleglisten und der "Zweck" der EüR-Anlage.
* Einreiche-Flow je Abrechnungstyp gefüllt hat -- bei Fahrtkosten ist `type_other` leer. *
* 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 class InvoicePurposeTest extends TestCase
{ {
@@ -49,7 +59,10 @@ class InvoicePurposeTest extends TestCase
app()->instance('tenant', $this->tenant); app()->instance('tenant', $this->tenant);
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]);
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. // Die Beitragserstattung bringt die Migration mit; die beiden anderen Typen nicht.
foreach ([ foreach ([
@@ -64,6 +77,12 @@ class InvoicePurposeTest extends TestCase
'counts_as_expense' => 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 private function makeCostUnit(): CostUnit
@@ -79,11 +98,29 @@ class InvoicePurposeTest extends TestCase
]); ]);
} }
private function purposeOf(array $attributes): string 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++; $this->sequence++;
$invoice = Invoice::create(array_merge([ return Invoice::create(array_merge([
'tenant' => $this->tenant->slug, 'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id, 'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => sprintf('2026-%04d', $this->sequence), 'invoice_number' => sprintf('2026-%04d', $this->sequence),
@@ -91,10 +128,104 @@ class InvoicePurposeTest extends TestCase
'contact_name' => 'Max Mustermann', 'contact_name' => 'Max Mustermann',
'amount' => 42.0, 'amount' => 42.0,
], $attributes)); ], $attributes));
return new InvoiceResource($invoice)->toArray()['purpose'];
} }
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 public function test_an_expense_shows_what_was_bought(): void
{ {
$this->assertSame('Bastelmaterial Sippenstunde', $this->purposeOf([ $this->assertSame('Bastelmaterial Sippenstunde', $this->purposeOf([
@@ -105,7 +236,8 @@ class InvoicePurposeTest extends TestCase
public function test_travel_costs_show_the_reason_and_who_travelled(): void public function test_travel_costs_show_the_reason_and_who_travelled(): void
{ {
// `type_other` bleibt bei Fahrtkosten leer -- die Strecke landet in `travel_direction`. // `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([ $this->assertSame('Landeslager — Max Mustermann', $this->purposeOf([
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING, 'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'travel_direction' => 'Halle Leipzig', 'travel_direction' => 'Halle Leipzig',
@@ -130,4 +262,68 @@ class InvoicePurposeTest extends TestCase
'type' => InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, '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());
}
} }
+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());
}
}