Moved bank transfer logic to special module

This commit is contained in:
2026-08-05 10:00:02 +02:00
parent d1ef092bc3
commit e55cdd1988
22 changed files with 669 additions and 165 deletions
@@ -1,8 +1,9 @@
<script setup>
import { computed, ref } from 'vue';
import {computed, ref} from 'vue';
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
@@ -95,7 +96,10 @@ async function save() {
<tr><th>Beschreibung</th><td><textarea v-model="form.description" class="form-input"></textarea></td></tr>
<tr v-for="option in editing.optionsSchema ?? []" :key="option.name">
<th>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></th>
<td><input type="text" v-model="form.configuration[option.name]" class="form-input" /></td>
<td>
<TextEditor v-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/>
<input v-else type="text" v-model="form.configuration[option.name]" class="form-input"/>
</td>
</tr>
<tr>
<th>Aktiv</th>
@@ -1,11 +1,12 @@
<script setup>
import {onMounted, reactive, ref} from "vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue";
import AmountInput from "../../../../Views/Components/AmountInput.vue";
import {request} from "../../../../../resources/js/components/HttpClient.js";
import {toast} from "vue3-toastify";
import {onMounted, reactive, ref} from "vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue";
import AmountInput from "../../../../Views/Components/AmountInput.vue";
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import {request} from "../../../../../resources/js/components/HttpClient.js";
import {toast} from "vue3-toastify";
const emit = defineEmits(['close'])
const emit = defineEmits(['close'])
const props = defineProps({
@@ -245,7 +246,11 @@
>
<div v-for="option in paymentMethod.optionsSchema" :key="option.name" class="payment-method-config-row">
<label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
<input type="text" v-model="paymentMethodConfigurations[paymentMethod.slug][option.name]" class="width-full" />
<TextEditor v-if="option.type === 'richtext'"
v-model="paymentMethodConfigurations[paymentMethod.slug][option.name]"/>
<input v-else type="text"
v-model="paymentMethodConfigurations[paymentMethod.slug][option.name]"
class="width-full"/>
</div>
</div>
</td>
@@ -26,30 +26,46 @@ const props = defineProps({
Du hast noch kein erweitertes Führungszeugnis hinterlegt. Bitte reiche es umgehend ein.
</div>
<template v-if="props.participant.needs_payment">
<table class="form-table" style="margin-bottom: 16px;">
<tr>
<td>Kontoinhaber:</td><td>{{ props.event.accountOwner }}</td>
<td rowspan="4" style="vertical-align: top; padding-left: 20px;" v-if="props.participant.identifier !== ''">
<img :src="'/print-girocode/' + props.participant.identifier" alt="GiroCode" style="max-width: 180px;" />
<span style="width: 180px; text-align: center; display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Giro-Code</span>
</td>
</tr>
<tr><td>IBAN:</td><td>{{ props.event.accountIban }}</td></tr>
<tr><td>Verwendungszweck:</td><td>{{ props.participant.payment_purpose }}</td></tr>
<tr><td>Betrag:</td><td><strong>{{ props.participant.amount_left_string }}</strong></td></tr>
<tr><td>&nbsp;</td><td>
<a class="link" :href="`/api/v1/event/participant/${props.participant.identifier}/ical-payment`">Erinnerung in Kalender setzen</a>
</td></tr>
</table>
<p>
Bitte beachte, dass deine Anmeldung erst nach Zahlungseiongang vollständig ist.<br />
Wenn dieser nicht bis zum {{ props.event.registrationFinalEnd.formatted }} erfolgt, kann deine Anmeldung storniert werden.<br /><br />
Solltest du den Beitrag bis zu diesem Datum nicht oder nur teilweise überweisen können, kontaktiere bitte die Aktionsleitung, damit wir eine gemeinsame Lösiung finden können.
</p>
<template v-if="props.participant.paymentSummary?.hasPaymentInformation">
<!-- Frei konfigurierter Text (z.B. Barzahlung) -->
<div v-if="props.participant.paymentSummary.html" v-html="props.participant.paymentSummary.html"
style="margin-bottom: 16px;"></div>
<!-- Strukturierte Zeilen (z.B. Überweisung) -->
<template v-else>
<table class="form-table" style="margin-bottom: 16px;">
<tr v-for="(line, index) in props.participant.paymentSummary.lines" :key="line.label">
<td>{{ line.label }}:</td>
<td><strong v-if="line.label === 'Betrag'">{{ line.value }}</strong>
<template v-else>{{ line.value }}</template>
</td>
<td v-if="index === 0 && props.participant.paymentSummary.showGiroCode && props.participant.identifier !== ''"
:rowspan="props.participant.paymentSummary.lines.length"
style="vertical-align: top; padding-left: 20px;">
<img :src="'/print-girocode/' + props.participant.identifier" alt="GiroCode"
style="max-width: 180px;"/>
<span
style="width: 180px; text-align: center; display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Giro-Code</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<a class="link"
:href="`/api/v1/event/participant/${props.participant.identifier}/ical-payment`">Erinnerung
in Kalender setzen</a>
</td>
</tr>
</table>
<p>
Bitte beachte, dass deine Anmeldung erst nach Zahlungseiongang vollständig ist.<br/>
Wenn dieser nicht bis zum {{ props.event.registrationFinalEnd.formatted }} erfolgt, kann deine
Anmeldung storniert werden.<br/><br/>
Solltest du den Beitrag bis zu diesem Datum nicht oder nur teilweise überweisen können, kontaktiere
bitte die Aktionsleitung, damit wir eine gemeinsame Lösiung finden können.
</p>
</template>
</template>
<p v-else>
Du musst keinen Beitrag überweisen. Deine Anmeldung ist bestätigt.
+89
View File
@@ -0,0 +1,89 @@
# Zahlungsmodule (`app/EventPaymentModules`)
Interface-gesteuerte Strategie-Schicht: Das Verhalten je Zahlungsart (Optionen, Anmelde-Zusammenfassung, Zahlung,
Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über den Slug.
## Aufbau
- `EventPaymentModule`**Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
`createInvoice()`.
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges).
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode`**Fähigkeits-Interface** (siehe unten).
## Kernregeln
- **Options-Schema lebt im Code**, nicht in der DB (`getOptions()` je Modul). In der DB stehen nur die Werte.
Optionsform: `['name', 'label', 'type', 'required']`. `type` ist i.d.R. `'string'`; `'richtext'` wird im Frontend über
`Views/Components/TextEditor.vue` (TinyMCE, HTML) gerendert und via `v-html`/`{!! !!}` ausgegeben.
- **Aktivierungs-Guard:** Eine Tenant-Zahlungsmethode darf nur `active` werden, wenn alle `required`-Optionen befüllt
sind (`isConfigurationComplete()`), erzwungen in `UpdateAvailablePaymentMethodAction`.
- **Config-Speicherung (JSON):** pro Tenant auf `available_payment_methods.configuration`, pro Event auf dem Pivot
`event_payment_methods.configuration`. Beim Zuweisen an ein Event wird die Tenant-Config als **Snapshot kopiert**
(Copy-on-Assign, spätere Tenant-Änderungen wirken NICHT nach). Sync erfolgt differenziell in `UpdateEventCommand`.
- **`PaymentMethod` ist nur eine Fassade:** `PaymentMethod::optionsFor/requiredOptionKeys/sanitizeConfiguration/`
`isConfigurationComplete/defaults()` delegieren an die Registry. Bestehende Aufrufstellen bleiben so stabil.
- **`PaymentStatus`** ist ein DB-gestütztes Enumerations-Model (`app/Enumerations/PaymentStatus.php`, Tabelle
`payment_status`, geseedet in `ProductionDataSeeder`), analog zu `InvoiceStatus`. Reine Steuersignale (z.B. Redirect)
gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.
- `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul.
## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation)
Verhalten, das **nur eine** Zahlungsart hat, gehört **nicht** auf `EventPaymentModule` und **nicht** auf das generische
`EventParticipant`-Model, sondern in ein schmales Fähigkeits-Interface, das nur das betreffende Modul implementiert.
Aufrufer prüfen per `instanceof`.
Beispiel GiroCode (nur Überweisung):
- `ProvidesGiroCode::giroCode(EventParticipant, array $configuration): ?string` — implementiert **nur** von
`AccountTransferPaymentModule`.
- Aufrufer (`GiroCodeGetController`, `EventSignUpSuccessfullMail`, `ParticipantPaymentMissingPaymentMail`) lösen inline
auf:
```php
$module = $participant->paymentModule();
$binary = $module instanceof ProvidesGiroCode
? $module->giroCode($participant, $participant->paymentConfiguration())
: null;
```
- Künftige Verfahren würden analog eigene Fähigkeiten mitbringen (z.B. `ProvidesRedirect` für PayPal,
`ProvidesMandate` für SEPA-Lastschrift) — **erst modellieren, wenn tatsächlich gebraucht.**
## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
Anzeige-Block render-agnostisch: entweder `lines` (label/value, z.B. Überweisung) oder `html` (Freitext, z.B.
Barzahlung), plus `hasPaymentInformation` und `showGiroCode`.
- Zugang über das Teilnehmer-Model: `EventParticipant::paymentModule()`, `paymentConfiguration()` (eager-load-fähig über
`->with('event.paymentMethods')`, kein statischer Cache), `paymentSummary()`.
- Konsumiert von `EventParticipantResource` (`paymentSummary`), `SubmitSuccess.vue`, `emails/subparts/payment.blade.php`
(+ `signup_complete`, `missing_amount`). GiroCode in Mails: **inline per CID** (`$message->embedData(...)`), im Web
über `/print-girocode/{token}` (sessionlos, lazy).
## Neues Zahlungsmodul hinzufügen
1. Klasse unter `Modules/` anlegen, `extends AbstractEventPaymentModule`; `slug()`, `defaultName()`, `getOptions()`
implementieren; `registrationSummary()`/`doPayment()`/`createInvoice()` überschreiben, wo nötig.
2. Zahlart-spezifische Extras als **eigenes Fähigkeits-Interface** (nicht ins Core-Interface).
3. In `EventPaymentModuleRegistry::MODULES` eintragen. `PaymentMethod::create(['slug' => …])` wird dann automatisch über
`ProductionDataSeeder` (iteriert `EventPaymentModuleRegistry::slugs()`) geseedet.
4. Slug-Konstante bei Bedarf auf `PaymentMethod` ergänzen.
## Datenübernahme
`storage/app/sync_payment_bank_data.php` überführt einmalig Bestands-Bankdaten (Tenant/Event) in die Modul-Config
(idempotent). Bei Live-Inbetriebnahme einmal ausführen.
## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5):
`docker exec mareike-mareike-app-1 php artisan test`.
@@ -2,23 +2,21 @@
namespace App\EventPaymentModules\DTO;
use App\Models\Event;
use App\ValueObjects\Amount;
use App\Models\EventParticipant;
/**
* Eingabe für {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}.
*
* Bewusst ohne persistierten EventParticipant: Die Zusammenfassung wird im Anmeldefluss VOR dem
* Absenden erzeugt, es existiert also noch kein Teilnehmer-Datensatz.
* Anker ist der (bereits erstellte) Teilnehmer -- daraus leiten die Module Event, Betrag/Restbetrag
* und Verwendungszweck ab. $configuration ist die aufgelöste pro-Event-Config des Zahlungsmoduls.
*/
final class RegistrationSummaryRequest
{
/**
* @param array<string, mixed> $configuration Aufgelöste (pro-Event-)Konfiguration des Moduls.
* @param array<string, mixed> $configuration
*/
public function __construct(
public readonly Event $event,
public readonly Amount $amount,
public readonly EventParticipant $participant,
public readonly array $configuration = [],
) {
}
@@ -5,17 +5,22 @@ namespace App\EventPaymentModules\DTO;
/**
* Ausgabe von {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}.
*
* Strukturierte Daten (kein reines Vue) -- speisen sowohl die Anmelde-Zusammenfassung im Frontend
* als auch spätere API-Actions. Der obere Teil der Zusammenfassung ist für alle gleich; hier kommt
* ausschließlich der zahlungsspezifische Abschnitt.
* Render-agnostisch: speist den Zahlungs-Abschnitt sowohl im Anmeldeabschluss (Vue) als auch in den Mails.
* Zwei Darstellungsformen:
* - strukturierte $lines (label/value) -- z.B. Überweisung (Kontoinhaber, IBAN, Verwendungszweck, Betrag),
* - $html -- frei konfigurierter Text (z.B. Barzahlung).
*/
final class RegistrationSummaryResponse
{
public string $title = '';
/** Gibt es überhaupt einen Zahlungs-Info-Block darzustellen? */
public bool $hasPaymentInformation = false;
/** @var array<int, array{label: string, value: string}> Zeilen des Zahlungs-Abschnitts. */
/** @var array<int, array{label: string, value: string}> */
public array $lines = [];
/** Bestätigungstext für die Checkbox, z.B. "Ich bestätige, den Betrag von X zu überweisen.". */
public ?string $confirmationLabel = null;
/** Frei konfigurierter HTML-Text (alternativ zu $lines). */
public ?string $html = null;
/** Soll der GiroCode (QR) angezeigt werden? */
public bool $showGiroCode = false;
}
@@ -4,12 +4,17 @@ namespace App\EventPaymentModules\Modules;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider;
/**
* Überweisung auf das Veranstaltungskonto -- die Zahlung erfolgt manuell durch die teilnehmende Person.
*/
class AccountTransferPaymentModule extends AbstractEventPaymentModule
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements ProvidesGiroCode
{
public static function slug(): string
{
@@ -30,6 +35,50 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule
];
}
public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{
$participant = $request->participant;
$config = $request->configuration;
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$response = new RegistrationSummaryResponse();
$response->hasPaymentInformation = $amountLeft->getAmount() > 0;
$response->showGiroCode = $response->hasPaymentInformation;
$response->lines = [
['label' => 'Kontoinhaber', 'value' => (string)($config['account_owner'] ?? '')],
['label' => 'IBAN', 'value' => (string)($config['iban'] ?? '')],
['label' => 'Verwendungszweck', 'value' => (string)$participant->payment_purpose],
['label' => 'Betrag', 'value' => $amountLeft->toString()],
];
return $response;
}
public function giroCode(EventParticipant $participant, array $configuration): ?string
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
if ($amountLeft->getAmount() <= 0) {
return null;
}
$provider = new GiroCodeProvider(
(string)($configuration['account_owner'] ?? ''),
(string)($configuration['iban'] ?? ''),
$amountLeft->getAmount(),
(string)$participant->payment_purpose,
);
return (string)$provider->create();
}
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{
// TODO: In einer Folge-Iteration ausformulieren (z.B. "Bitte überweise bis zum ... auf IBAN ...").
@@ -4,10 +4,13 @@ namespace App\EventPaymentModules\Modules;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\Models\PaymentMethod;
/**
* Sonstiges (Barzahlung, Zahlung vor Ort) -- keine konfigurierbaren Optionen, keine aktive Zahlung.
* Sonstiges (Barzahlung, Zahlung vor Ort) -- die anzuzeigende Zahlungsinformation ist nutzerdefiniert
* (mehrzeiliger Freitext) und wird als Option gepflegt.
*/
class UndefinedPaymentModule extends AbstractEventPaymentModule
{
@@ -23,7 +26,21 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
public function getOptions(): array
{
return [];
return [
['name' => 'payment_information', 'label' => 'Zahlungsinformationen', 'type' => 'richtext', 'required' => true],
];
}
public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{
$html = (string)($request->configuration['payment_information'] ?? '');
$response = new RegistrationSummaryResponse();
$response->hasPaymentInformation = trim($html) !== '';
$response->html = $html;
$response->showGiroCode = false;
return $response;
}
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
@@ -0,0 +1,21 @@
<?php
namespace App\EventPaymentModules;
use App\Models\EventParticipant;
/**
* Fähigkeits-Interface (Interface Segregation): Nur Zahlungsarten mit einem GiroCode (SEPA-QR)
* implementieren dies -- aktuell ausschließlich die Überweisung. Das Core-Interface
* {@see EventPaymentModule} bleibt davon unberührt.
*/
interface ProvidesGiroCode
{
/**
* Erzeugt den GiroCode (SEPA-QR) als PNG-Binary -- oder null, wenn (z.B. mangels offenem Betrag)
* kein GiroCode angeboten wird.
*
* @param array<string, mixed> $configuration
*/
public function giroCode(EventParticipant $participant, array $configuration): ?string;
}
+10 -12
View File
@@ -2,8 +2,8 @@
namespace App\Http\Controllers;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant;
use App\Providers\GiroCodeProvider;
use Illuminate\Http\Request;
class GiroCodeGetController {
@@ -14,18 +14,16 @@ class GiroCodeGetController {
return response()->json(['message' => 'Participant not found'], 404);
}
$amount = $participant->amount;
$amount->subtractAmount($participant->amount_paid);
// Die GiroCode-Erzeugung gehört zum Zahlungsmodul; ohne diese Fähigkeit (z.B. Barzahlung) -> 404.
$module = $participant->paymentModule();
$girocode = $module instanceof ProvidesGiroCode
? $module->giroCode($participant, $participant->paymentConfiguration())
: null;
$girocodeProvider = new GiroCodeProvider(
$participant->event()->first()->account_owner,
$participant->event()->first()->account_iban,
$participant->amount->getAmount(),
$participant->payment_purpose
);
if ($girocode === null) {
return response()->json(['message' => 'No giro code for this payment method'], 404);
}
return response($girocodeProvider->create(), 200,
['Content-Type' => 'image/png']
);
return response($girocode, 200, ['Content-Type' => 'image/png']);
}
}
@@ -8,6 +8,7 @@ use App\Domains\Event\Actions\GenerateIcal\GenerateIcalResponse;
use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineCommand;
use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineRequest;
use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineResponse;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant;
use Illuminate\Bus\Queueable;
use Illuminate\Http\Request;
@@ -62,13 +63,10 @@ class EventSignUpSuccessfullMail extends Mailable
$event = $this->participant->event()->first()->toResource()->toArray(new Request());
$participant = $this->participant->toResource()->toArray(new Request());
$girocodeProvider = new \App\Providers\GiroCodeProvider(
$event['accountOwner'],
$event['accountIban'],
(float) $participant['amount_left_value'],
$participant['payment_purpose']
);
$girocodeBinary = (string)$girocodeProvider->create();
$module = $this->participant->paymentModule();
$girocodeBinary = $module instanceof ProvidesGiroCode
? $module->giroCode($this->participant, $this->participant->paymentConfiguration())
: null;
$participationIcal = $this->getParticipationIcal();
$deadlineIcal = $this->getDeadlineIcal();
@@ -85,10 +83,7 @@ class EventSignUpSuccessfullMail extends Mailable
'departure' => $participant['departure'],
'amount' => $participant['amount_left_string'],
'paymentFinalDate' => $event['registrationFinalEnd']['formatted'],
'paymentRequired' => $participant['needs_payment'],
'accountOwner' => $event['accountOwner'],
'accountIban' => $event['accountIban'],
'paymentPurpose' => $participant['payment_purpose'],
'paymentSummary' => $participant['paymentSummary'],
'girocodeBinary' => $girocodeBinary,
'efzStatus' => $participant['efz_status'],
'participationIcalFilename' => $participationIcal->filename,
@@ -2,6 +2,7 @@
namespace App\Mail\ParticipantPaymentMails;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant;
use Illuminate\Http\Request;
use Illuminate\Mail\Mailable;
@@ -45,13 +46,10 @@ class ParticipantPaymentMissingPaymentMail extends Mailable
$event = $this->participant->event()->first()->toResource()->toArray(new Request());
$participant = $this->participant->toResource()->toArray(new Request());
$girocodeProvider = new \App\Providers\GiroCodeProvider(
$event['accountOwner'],
$event['accountIban'],
(float) $participant['amount_left_value'],
$participant['payment_purpose']
);
$girocodeBinary = (string)$girocodeProvider->create();
$module = $this->participant->paymentModule();
$girocodeBinary = $module instanceof ProvidesGiroCode
? $module->giroCode($this->participant, $this->participant->paymentConfiguration())
: null;
return new Content(
view: 'emails.participantPayments.missing_amount',
@@ -64,10 +62,7 @@ class ParticipantPaymentMissingPaymentMail extends Mailable
'departure' => $participant['departure'],
'amount' => $participant['amount_left_string'],
'paymentFinalDate' => $event['registrationFinalEnd']['formatted'],
'paymentRequired' => $participant['needs_payment'],
'accountOwner' => $event['accountOwner'],
'accountIban' => $event['accountIban'],
'paymentPurpose' => $participant['payment_purpose'],
'paymentSummary' => $participant['paymentSummary'],
'girocodeBinary' => $girocodeBinary,
],
);
+47 -1
View File
@@ -8,8 +8,11 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\EventPaymentModule;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\Scopes\InstancedModel;
use Illuminate\Database\Eloquent\Builder;
class EventParticipant extends InstancedModel
@@ -166,4 +169,47 @@ class EventParticipant extends InstancedModel
return $this->nickname ?? $this->firstname;
}
/*
|--------------------------------------------------------------------------
| Zahlungsmodul
|--------------------------------------------------------------------------
*/
/** Das Zahlungsmodul der gewählten Zahlungsart des Teilnehmers. */
public function paymentModule(): ?EventPaymentModule
{
return $this->payment_method !== null
? EventPaymentModuleRegistry::forSlug($this->payment_method)
: null;
}
/**
* Aufgelöste pro-Event-Konfiguration der gewählten Zahlungsart (event_payment_methods.configuration).
*
* Nutzt die Relation-Property, damit sie via `->with('event.paymentMethods')` eager geladen und in
* Teilnehmerlisten (gleiches Event teilt dieselbe Event-Instanz) ohne N+1 aufgelöst werden kann.
*
* @return array<string, mixed>
*/
public function paymentConfiguration(): array
{
if ($this->payment_method === null) {
return [];
}
$method = $this->event?->paymentMethods->firstWhere('slug', $this->payment_method);
return $method?->pivot->configuration ?? [];
}
/** Zahlungs-Zusammenfassung (Anmeldeabschluss/Mail), vom Zahlungsmodul erzeugt. */
public function paymentSummary(): RegistrationSummaryResponse
{
$module = $this->paymentModule();
if ($module === null) {
return new RegistrationSummaryResponse();
}
return $module->registrationSummary(new RegistrationSummaryRequest($this, $this->paymentConfiguration()));
}
}
@@ -27,6 +27,8 @@ class EventParticipantResource extends JsonResource
$amountLeft->subtractAmount($this->resource->amount_paid);
}
$paymentSummary = $this->resource->paymentSummary();
$presenceDays = $this->resource->arrival_date->diff($this->resource->departure_date)->days;
if ($presenceDays === 0) {
$presenceDays = 1;
@@ -57,6 +59,12 @@ class EventParticipantResource extends JsonResource
'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(),
'paymentSummary' => [
'hasPaymentInformation' => $paymentSummary->hasPaymentInformation,
'lines' => $paymentSummary->lines,
'html' => $paymentSummary->html,
'showGiroCode' => $paymentSummary->showGiroCode,
],
'nicename' => $this->resource->getNicename(),
'arrival' => $this->resource->arrival_date->format('d.m.Y'),
'departure' => $this->resource->departure_date->format('d.m.Y'),