Development 4.6.0 #12

Merged
th.guenther merged 8 commits from development-4.6.0 into main 2026-08-05 11:07:58 +02:00
22 changed files with 669 additions and 165 deletions
Showing only changes of commit e55cdd1988 - Show all commits
+17
View File
@@ -0,0 +1,17 @@
# CLAUDE.md
Laravel 12 + Inertia/Vue, mandantenfähig (custom `tenant`-Scoping über `app('tenant')`, gesetzt in
`app/Middleware/IdentifyTenant.php`). PHP 8.5 — Tests/Artisan laufen im Container:
`docker exec mareike-mareike-app-1 php artisan test`.
## Projektkonventionen (verbindlich)
Die verbindlichen Konventionen (Actions/Request-Command-Response, Controller, Repositories, Models/Resources, Tenant,
Routing, Mails) stehen in `.ai/conventions.md` und werden hier eingebunden — **immer beachten**:
@.ai/conventions.md
## Bereichs-Dokumentation
- [Zahlungsmodule](app/EventPaymentModules/CLAUDE.md) — interface-gesteuerte Zahlungsarten (Optionen, Config,
Anmelde-Zusammenfassung, GiroCode/Fähigkeits-Interfaces, neues Modul hinzufügen).
@@ -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">
<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>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>&nbsp;</td>
<td>
<a class="link"
:href="`/api/v1/event/participant/${props.participant.identifier}/ical-payment`">Erinnerung
in Kalender setzen</a>
</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.
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'),
@@ -38,15 +38,19 @@
</tr>
</table>
@if ($paymentRequired)
@if ($paymentSummary['hasPaymentInformation'])
@if ($paymentSummary['showGiroCode'])
<p>
Deine Teilnahme ist <strong>noch nicht bestätigt,</strong> da dsies erst nach vollständigem Zahlungseingang der
Deine Teilnahme ist <strong>noch nicht bestätigt,</strong> da dsies erst nach vollständigem Zahlungseingang
der
Fall ist.
Bitte zahle den Betrag in Höhe von <strong>{{$amount}}</strong> bis zum <strong>{{$paymentFinalDate}},</strong>
Bitte zahle den Betrag in Höhe von <strong>{{$amount}}</strong> bis zum <strong>{{$paymentFinalDate}}
,</strong>
da andernfalls die Stornierung deiner Anmeldung erfolgen kann.
Um deine Anmeldung zu vervollständigen, überweise den Betrag bitte zugunsten folgender Bankverbindung:
</p>
@endif
<p>
@include('emails.subparts.payment')<br />
@@ -36,7 +36,7 @@
</tr>
</table>
@if ($paymentRequired)
@if ($paymentSummary['hasPaymentInformation'] && $paymentSummary['showGiroCode'])
<p>
Wahrscheinlich hast du vergessen, deinen Teilnahmebeitrag zu überweisen, sodass deine Anmeldung leider <strong>noch nicht bestätigt,</strong> werden konnte.
@@ -1,45 +1,28 @@
<table cellpadding="0" cellspacing="0" border="0"
{{-- Zahlungs-Info-Block aus dem Zahlungsmodul ($paymentSummary). Entweder freier HTML-Text
(z.B. Barzahlung) oder strukturierte Zeilen (z.B. Überweisung) inkl. optionalem GiroCode. --}}
@if (!empty($paymentSummary['html']))
{!! $paymentSummary['html'] !!}
@else
<table cellpadding="0" cellspacing="0" border="0"
style="width: 100%; max-width: 640px; border-collapse: collapse; font-family: Arial, sans-serif; font-size: 14px; color: #1f2937;">
@foreach ($paymentSummary['lines'] as $line)
<tr>
<td style="padding: 8px 12px; width: 180px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;">
Kontoinhaber*in
<td style="padding: {{ $loop->last ? '10px' : '8px' }} 12px; width: 180px; font-weight: {{ $loop->last ? '700' : '600' }}; color: {{ $loop->last ? '#111827' : '#4b5563' }}; border-{{ $loop->last ? 'top: 2px solid #d1d5db' : 'bottom: 1px solid #e5e7eb' }};">
{{ $line['label'] }}
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
{{ $accountOwner }}
<td style="padding: {{ $loop->last ? '10px' : '8px' }} 12px; font-weight: {{ $loop->last ? '700' : '400' }}; color: {{ $loop->last ? '#111827' : '#1f2937' }}; border-{{ $loop->last ? 'top: 2px solid #d1d5db' : 'bottom: 1px solid #e5e7eb' }};">
{{ $line['value'] }}
</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;">
IBAN
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-family: monospace;">
{{ $accountIban }}
</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;">
Verwendungszweck
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
{{ $paymentPurpose }}
</td>
</tr>
<tr>
<td style="padding: 10px 12px; font-weight: 700; color: #111827; border-top: 2px solid #d1d5db;">
Betrag
</td>
<td style="padding: 10px 12px; font-weight: 700; color: #111827; border-top: 2px solid #d1d5db;">
{{ $amount }}
</td>
</tr>
</table>
@endforeach
</table>
@php
@if (!empty($paymentSummary['showGiroCode']))
@php
$girocodeSrc = $message->embedData($girocodeBinary, 'girocode.png', 'image/png');
@endphp
<table style="margin: 20px;">
@endphp
<table style="margin: 20px;">
<tr style="vertical-align: top; background-color: #eaeaea; border-spacing: 0">
<td style="padding: 20px; text-align: center; font-weight: bold;">
<strong>
Bequem überweisen mit QR-Code
@@ -63,10 +46,13 @@
</p>
</td>
</tr>
</table>
</table>
<p>
Bitte zahle immer mit dem vorgegeben Betreff, damit deine Zahlung möglichst einfach zugeordnet werden kann.<br/>
<p>
Bitte zahle immer mit dem vorgegeben Betreff, damit deine Zahlung möglichst einfach zugeordnet werden
kann.<br/>
Sollte die Zahlung innerhalb dieser Frist nicht oder nur teilweise möglich sein, kontaktiere bitte die
Aktionsleitung, sodass wir eine gemeinsame Lösung finden können.
</p>
</p>
@endif
@endif
@@ -0,0 +1,147 @@
<?php
namespace Tests\Feature;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventPaymentMethods;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class EventParticipantPaymentSummaryTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
}
private function createEventWithTransfer(array $config): Event
{
$event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
AvailablePaymentMethod::create([
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'name' => 'Überweisung', 'active' => true, 'configuration' => $config,
]);
EventPaymentMethods::create([
'event_id' => $event->id,
'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'configuration' => $config,
]);
return $event;
}
private function createParticipant(Event $event, float $amount, string $paymentMethod = PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION): EventParticipant
{
return EventParticipant::create([
'tenant' => $this->tenant->slug, 'event_id' => $event->id, 'identifier' => 'p-1',
'firstname' => 'Hans', 'lastname' => 'Muster', 'participation_type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '00000', 'city' => 'Stadt',
'email_1' => 'h@example.com', 'phone_1' => '123', 'arrival_date' => '2026-08-01', 'departure_date' => '2026-08-05',
'arrival_eating' => 0, 'departure_eating' => 0, 'amount' => $amount, 'payment_purpose' => 'Event - Beitrag Hans Muster',
'efz_status' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'payment_method' => $paymentMethod,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
]);
}
public function test_transfer_module_produces_giro_code(): void
{
$event = $this->createEventWithTransfer(['account_owner' => 'Kasse', 'iban' => 'DE-EVENT']);
$participant = $this->createParticipant($event, 50.0);
$module = $participant->paymentModule();
$this->assertInstanceOf(ProvidesGiroCode::class, $module);
$giro = $module->giroCode($participant, $participant->paymentConfiguration());
$this->assertNotNull($giro);
$this->assertNotSame('', $giro);
}
public function test_cash_module_provides_no_giro_code(): void
{
$event = $this->createEventWithTransfer(['account_owner' => 'Kasse', 'iban' => 'DE-EVENT']);
$participant = $this->createParticipant($event, 50.0, PaymentMethod::PAYMENT_NOT_DEFINED);
$this->assertNotInstanceOf(ProvidesGiroCode::class, $participant->paymentModule());
}
public function test_payment_configuration_resolves_event_pivot_config(): void
{
$event = $this->createEventWithTransfer(['account_owner' => 'Kasse', 'iban' => 'DE-EVENT']);
$participant = $this->createParticipant($event, 50.0);
$config = $participant->paymentConfiguration();
$this->assertSame('DE-EVENT', $config['iban']);
$this->assertSame('Kasse', $config['account_owner']);
}
public function test_payment_summary_builds_lines_from_resolved_config(): void
{
$event = $this->createEventWithTransfer(['account_owner' => 'Kasse', 'iban' => 'DE-EVENT']);
$participant = $this->createParticipant($event, 50.0);
$summary = $participant->paymentSummary();
$this->assertTrue($summary->hasPaymentInformation);
$this->assertTrue($summary->showGiroCode);
$byLabel = [];
foreach ($summary->lines as $line) {
$byLabel[$line['label']] = $line['value'];
}
$this->assertSame('DE-EVENT', $byLabel['IBAN']);
}
public function test_resource_exposes_payment_summary(): void
{
$event = $this->createEventWithTransfer(['account_owner' => 'Kasse', 'iban' => 'DE-EVENT']);
$participant = $this->createParticipant($event, 50.0);
$array = $participant->toResource()->toArray(request());
$this->assertTrue($array['paymentSummary']['hasPaymentInformation']);
$this->assertTrue($array['paymentSummary']['showGiroCode']);
}
}
@@ -5,6 +5,7 @@ namespace Tests\Unit;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\PaymentMethod;
use PHPUnit\Framework\TestCase;
@@ -49,6 +50,12 @@ class EventPaymentModuleRegistryTest extends TestCase
$this->assertTrue($module->isConfigurationComplete(['iban' => 'DE123', 'account_owner' => 'Kasse']));
}
public function test_only_account_transfer_provides_giro_code(): void
{
$this->assertInstanceOf(ProvidesGiroCode::class, new AccountTransferPaymentModule());
$this->assertNotInstanceOf(ProvidesGiroCode::class, new UndefinedPaymentModule());
}
public function test_defaults_are_built_from_modules(): void
{
$defaults = PaymentMethod::defaults();
+3 -2
View File
@@ -52,8 +52,9 @@ class PaymentMethodOptionsTest extends TestCase
$this->assertTrue(PaymentMethod::isConfigurationComplete($slug, ['iban' => 'DE123', 'account_owner' => 'Kasse']));
}
public function test_slug_without_options_is_always_complete(): void
public function test_unknown_slug_is_always_complete(): void
{
$this->assertTrue(PaymentMethod::isConfigurationComplete(PaymentMethod::PAYMENT_NOT_DEFINED, []));
// Kein Modul -> keine Pflichtfelder -> stets vollständig.
$this->assertTrue(PaymentMethod::isConfigurationComplete('NO_MODULE', []));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
use PHPUnit\Framework\TestCase;
class RegistrationSummaryTest extends TestCase
{
private function participant(float $amount, float $amountPaid, string $purpose = 'Zweck'): EventParticipant
{
$participant = new EventParticipant();
$participant->setRawAttributes([
'payment_purpose' => $purpose,
], true);
// Amount-Casts umgehen und die Value-Objects direkt setzen.
$participant->amount = new Amount($amount, ' Euro');
$participant->amount_paid = new Amount($amountPaid, ' Euro');
return $participant;
}
public function test_account_transfer_summary_builds_lines_from_config(): void
{
$module = new AccountTransferPaymentModule();
$request = new RegistrationSummaryRequest(
$this->participant(50.0, 0.0, 'Camp - Beitrag'),
['account_owner' => 'Kasse', 'iban' => 'DE123', 'bic' => 'XY'],
);
$summary = $module->registrationSummary($request);
$this->assertTrue($summary->hasPaymentInformation);
$this->assertTrue($summary->showGiroCode);
$this->assertNull($summary->html);
$byLabel = [];
foreach ($summary->lines as $line) {
$byLabel[$line['label']] = $line['value'];
}
$this->assertSame('Kasse', $byLabel['Kontoinhaber']);
$this->assertSame('DE123', $byLabel['IBAN']);
$this->assertSame('Camp - Beitrag', $byLabel['Verwendungszweck']);
$this->assertArrayHasKey('Betrag', $byLabel);
}
public function test_account_transfer_without_open_amount_has_no_payment_information(): void
{
$module = new AccountTransferPaymentModule();
$summary = $module->registrationSummary(new RegistrationSummaryRequest(
$this->participant(50.0, 50.0),
['account_owner' => 'Kasse', 'iban' => 'DE123'],
));
$this->assertFalse($summary->hasPaymentInformation);
$this->assertFalse($summary->showGiroCode);
}
public function test_cash_summary_returns_configured_html(): void
{
$module = new UndefinedPaymentModule();
$summary = $module->registrationSummary(new RegistrationSummaryRequest(
$this->participant(50.0, 0.0),
['payment_information' => '<p>Bitte bar vor Ort zahlen.</p>'],
));
$this->assertTrue($summary->hasPaymentInformation);
$this->assertFalse($summary->showGiroCode);
$this->assertSame('<p>Bitte bar vor Ort zahlen.</p>', $summary->html);
$this->assertSame([], $summary->lines);
}
public function test_cash_summary_empty_when_no_text_configured(): void
{
$module = new UndefinedPaymentModule();
$summary = $module->registrationSummary(new RegistrationSummaryRequest(
$this->participant(50.0, 0.0),
[],
));
$this->assertFalse($summary->hasPaymentInformation);
}
public function test_cash_module_requires_payment_information_option(): void
{
$module = new UndefinedPaymentModule();
$this->assertSame(['payment_information'], $module->requiredOptionKeys());
$this->assertFalse($module->isConfigurationComplete([]));
$this->assertTrue($module->isConfigurationComplete(['payment_information' => '<p>Text</p>']));
}
}