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> <script setup>
import { computed, ref } from 'vue'; import {computed, ref} from 'vue';
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js"; import TextEditor from "../../../../Views/Components/TextEditor.vue";
import { toast } from "vue3-toastify"; import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import {toast} from "vue3-toastify";
const props = defineProps({ const props = defineProps({
data: { 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><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"> <tr v-for="option in editing.optionsSchema ?? []" :key="option.name">
<th>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></th> <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>
<tr> <tr>
<th>Aktiv</th> <th>Aktiv</th>
@@ -1,11 +1,12 @@
<script setup> <script setup>
import {onMounted, reactive, ref} from "vue"; import {onMounted, reactive, ref} from "vue";
import ErrorText from "../../../../Views/Components/ErrorText.vue"; import ErrorText from "../../../../Views/Components/ErrorText.vue";
import AmountInput from "../../../../Views/Components/AmountInput.vue"; import AmountInput from "../../../../Views/Components/AmountInput.vue";
import {request} from "../../../../../resources/js/components/HttpClient.js"; import TextEditor from "../../../../Views/Components/TextEditor.vue";
import {toast} from "vue3-toastify"; import {request} from "../../../../../resources/js/components/HttpClient.js";
import {toast} from "vue3-toastify";
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const props = defineProps({ const props = defineProps({
@@ -245,7 +246,11 @@
> >
<div v-for="option in paymentMethod.optionsSchema" :key="option.name" class="payment-method-config-row"> <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> <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>
</div> </div>
</td> </td>
@@ -26,30 +26,46 @@ const props = defineProps({
Du hast noch kein erweitertes Führungszeugnis hinterlegt. Bitte reiche es umgehend ein. Du hast noch kein erweitertes Führungszeugnis hinterlegt. Bitte reiche es umgehend ein.
</div> </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;"> <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> <tr>
<td>Kontoinhaber:</td><td>{{ props.event.accountOwner }}</td> <td>&nbsp;</td>
<td rowspan="4" style="vertical-align: top; padding-left: 20px;" v-if="props.participant.identifier !== ''"> <td>
<img :src="'/print-girocode/' + props.participant.identifier" alt="GiroCode" style="max-width: 180px;" /> <a class="link"
<span style="width: 180px; text-align: center; display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Giro-Code</span> :href="`/api/v1/event/participant/${props.participant.identifier}/ical-payment`">Erinnerung
in Kalender setzen</a>
</td> </td>
</tr> </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> </table>
<p> <p>
Bitte beachte, dass deine Anmeldung erst nach Zahlungseiongang vollständig ist.<br /> 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 /> Wenn dieser nicht bis zum {{ props.event.registrationFinalEnd.formatted }} erfolgt, kann deine
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. 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> </p>
</template>
</template> </template>
<p v-else> <p v-else>
Du musst keinen Beitrag überweisen. Deine Anmeldung ist bestätigt. 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; namespace App\EventPaymentModules\DTO;
use App\Models\Event; use App\Models\EventParticipant;
use App\ValueObjects\Amount;
/** /**
* Eingabe für {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}. * Eingabe für {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}.
* *
* Bewusst ohne persistierten EventParticipant: Die Zusammenfassung wird im Anmeldefluss VOR dem * Anker ist der (bereits erstellte) Teilnehmer -- daraus leiten die Module Event, Betrag/Restbetrag
* Absenden erzeugt, es existiert also noch kein Teilnehmer-Datensatz. * und Verwendungszweck ab. $configuration ist die aufgelöste pro-Event-Config des Zahlungsmoduls.
*/ */
final class RegistrationSummaryRequest final class RegistrationSummaryRequest
{ {
/** /**
* @param array<string, mixed> $configuration Aufgelöste (pro-Event-)Konfiguration des Moduls. * @param array<string, mixed> $configuration
*/ */
public function __construct( public function __construct(
public readonly Event $event, public readonly EventParticipant $participant,
public readonly Amount $amount,
public readonly array $configuration = [], public readonly array $configuration = [],
) { ) {
} }
@@ -5,17 +5,22 @@ namespace App\EventPaymentModules\DTO;
/** /**
* Ausgabe von {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}. * Ausgabe von {@see \App\EventPaymentModules\EventPaymentModule::registrationSummary()}.
* *
* Strukturierte Daten (kein reines Vue) -- speisen sowohl die Anmelde-Zusammenfassung im Frontend * Render-agnostisch: speist den Zahlungs-Abschnitt sowohl im Anmeldeabschluss (Vue) als auch in den Mails.
* als auch spätere API-Actions. Der obere Teil der Zusammenfassung ist für alle gleich; hier kommt * Zwei Darstellungsformen:
* ausschließlich der zahlungsspezifische Abschnitt. * - strukturierte $lines (label/value) -- z.B. Überweisung (Kontoinhaber, IBAN, Verwendungszweck, Betrag),
* - $html -- frei konfigurierter Text (z.B. Barzahlung).
*/ */
final class RegistrationSummaryResponse 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 = []; public array $lines = [];
/** Bestätigungstext für die Checkbox, z.B. "Ich bestätige, den Betrag von X zu überweisen.". */ /** Frei konfigurierter HTML-Text (alternativ zu $lines). */
public ?string $confirmationLabel = null; 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\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; 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\Models\PaymentMethod;
use App\Providers\GiroCodeProvider;
/** /**
* Überweisung auf das Veranstaltungskonto -- die Zahlung erfolgt manuell durch die teilnehmende Person. * Ü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 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 protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{ {
// TODO: In einer Folge-Iteration ausformulieren (z.B. "Bitte überweise bis zum ... auf IBAN ..."). // 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\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest; use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\Models\PaymentMethod; 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 class UndefinedPaymentModule extends AbstractEventPaymentModule
{ {
@@ -23,7 +26,21 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
public function getOptions(): array 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 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; namespace App\Http\Controllers;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Providers\GiroCodeProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class GiroCodeGetController { class GiroCodeGetController {
@@ -14,18 +14,16 @@ class GiroCodeGetController {
return response()->json(['message' => 'Participant not found'], 404); return response()->json(['message' => 'Participant not found'], 404);
} }
$amount = $participant->amount; // Die GiroCode-Erzeugung gehört zum Zahlungsmodul; ohne diese Fähigkeit (z.B. Barzahlung) -> 404.
$amount->subtractAmount($participant->amount_paid); $module = $participant->paymentModule();
$girocode = $module instanceof ProvidesGiroCode
? $module->giroCode($participant, $participant->paymentConfiguration())
: null;
$girocodeProvider = new GiroCodeProvider( if ($girocode === null) {
$participant->event()->first()->account_owner, return response()->json(['message' => 'No giro code for this payment method'], 404);
$participant->event()->first()->account_iban, }
$participant->amount->getAmount(),
$participant->payment_purpose
);
return response($girocodeProvider->create(), 200, return response($girocode, 200, ['Content-Type' => 'image/png']);
['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\GenerateIcalForDeadlineCommand;
use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineRequest; use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineRequest;
use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineResponse; use App\Domains\Event\Actions\GenerateIcalForDeadline\GenerateIcalForDeadlineResponse;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -62,13 +63,10 @@ class EventSignUpSuccessfullMail extends Mailable
$event = $this->participant->event()->first()->toResource()->toArray(new Request()); $event = $this->participant->event()->first()->toResource()->toArray(new Request());
$participant = $this->participant->toResource()->toArray(new Request()); $participant = $this->participant->toResource()->toArray(new Request());
$girocodeProvider = new \App\Providers\GiroCodeProvider( $module = $this->participant->paymentModule();
$event['accountOwner'], $girocodeBinary = $module instanceof ProvidesGiroCode
$event['accountIban'], ? $module->giroCode($this->participant, $this->participant->paymentConfiguration())
(float) $participant['amount_left_value'], : null;
$participant['payment_purpose']
);
$girocodeBinary = (string)$girocodeProvider->create();
$participationIcal = $this->getParticipationIcal(); $participationIcal = $this->getParticipationIcal();
$deadlineIcal = $this->getDeadlineIcal(); $deadlineIcal = $this->getDeadlineIcal();
@@ -85,10 +83,7 @@ class EventSignUpSuccessfullMail extends Mailable
'departure' => $participant['departure'], 'departure' => $participant['departure'],
'amount' => $participant['amount_left_string'], 'amount' => $participant['amount_left_string'],
'paymentFinalDate' => $event['registrationFinalEnd']['formatted'], 'paymentFinalDate' => $event['registrationFinalEnd']['formatted'],
'paymentRequired' => $participant['needs_payment'], 'paymentSummary' => $participant['paymentSummary'],
'accountOwner' => $event['accountOwner'],
'accountIban' => $event['accountIban'],
'paymentPurpose' => $participant['payment_purpose'],
'girocodeBinary' => $girocodeBinary, 'girocodeBinary' => $girocodeBinary,
'efzStatus' => $participant['efz_status'], 'efzStatus' => $participant['efz_status'],
'participationIcalFilename' => $participationIcal->filename, 'participationIcalFilename' => $participationIcal->filename,
@@ -2,6 +2,7 @@
namespace App\Mail\ParticipantPaymentMails; namespace App\Mail\ParticipantPaymentMails;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
@@ -45,13 +46,10 @@ class ParticipantPaymentMissingPaymentMail extends Mailable
$event = $this->participant->event()->first()->toResource()->toArray(new Request()); $event = $this->participant->event()->first()->toResource()->toArray(new Request());
$participant = $this->participant->toResource()->toArray(new Request()); $participant = $this->participant->toResource()->toArray(new Request());
$girocodeProvider = new \App\Providers\GiroCodeProvider( $module = $this->participant->paymentModule();
$event['accountOwner'], $girocodeBinary = $module instanceof ProvidesGiroCode
$event['accountIban'], ? $module->giroCode($this->participant, $this->participant->paymentConfiguration())
(float) $participant['amount_left_value'], : null;
$participant['payment_purpose']
);
$girocodeBinary = (string)$girocodeProvider->create();
return new Content( return new Content(
view: 'emails.participantPayments.missing_amount', view: 'emails.participantPayments.missing_amount',
@@ -64,10 +62,7 @@ class ParticipantPaymentMissingPaymentMail extends Mailable
'departure' => $participant['departure'], 'departure' => $participant['departure'],
'amount' => $participant['amount_left_string'], 'amount' => $participant['amount_left_string'],
'paymentFinalDate' => $event['registrationFinalEnd']['formatted'], 'paymentFinalDate' => $event['registrationFinalEnd']['formatted'],
'paymentRequired' => $participant['needs_payment'], 'paymentSummary' => $participant['paymentSummary'],
'accountOwner' => $event['accountOwner'],
'accountIban' => $event['accountIban'],
'paymentPurpose' => $participant['payment_purpose'],
'girocodeBinary' => $girocodeBinary, 'girocodeBinary' => $girocodeBinary,
], ],
); );
+47 -1
View File
@@ -8,8 +8,11 @@ use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission; use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType; use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission; 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 App\Scopes\InstancedModel;
use Illuminate\Database\Eloquent\Builder;
class EventParticipant extends InstancedModel class EventParticipant extends InstancedModel
@@ -166,4 +169,47 @@ class EventParticipant extends InstancedModel
return $this->nickname ?? $this->firstname; 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); $amountLeft->subtractAmount($this->resource->amount_paid);
} }
$paymentSummary = $this->resource->paymentSummary();
$presenceDays = $this->resource->arrival_date->diff($this->resource->departure_date)->days; $presenceDays = $this->resource->arrival_date->diff($this->resource->departure_date)->days;
if ($presenceDays === 0) { if ($presenceDays === 0) {
$presenceDays = 1; $presenceDays = 1;
@@ -57,6 +59,12 @@ class EventParticipantResource extends JsonResource
'needs_payment' => $this->resource->amount->getAmount() > 0 'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION && $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(), && $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(), 'nicename' => $this->resource->getNicename(),
'arrival' => $this->resource->arrival_date->format('d.m.Y'), 'arrival' => $this->resource->arrival_date->format('d.m.Y'),
'departure' => $this->resource->departure_date->format('d.m.Y'), 'departure' => $this->resource->departure_date->format('d.m.Y'),
@@ -38,15 +38,19 @@
</tr> </tr>
</table> </table>
@if ($paymentRequired) @if ($paymentSummary['hasPaymentInformation'])
@if ($paymentSummary['showGiroCode'])
<p> <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. 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. da andernfalls die Stornierung deiner Anmeldung erfolgen kann.
Um deine Anmeldung zu vervollständigen, überweise den Betrag bitte zugunsten folgender Bankverbindung: Um deine Anmeldung zu vervollständigen, überweise den Betrag bitte zugunsten folgender Bankverbindung:
</p> </p>
@endif
<p> <p>
@include('emails.subparts.payment')<br /> @include('emails.subparts.payment')<br />
@@ -36,7 +36,7 @@
</tr> </tr>
</table> </table>
@if ($paymentRequired) @if ($paymentSummary['hasPaymentInformation'] && $paymentSummary['showGiroCode'])
<p> <p>
Wahrscheinlich hast du vergessen, deinen Teilnahmebeitrag zu überweisen, sodass deine Anmeldung leider <strong>noch nicht bestätigt,</strong> werden konnte. 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;"> 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> <tr>
<td style="padding: 8px 12px; width: 180px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;"> <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' }};">
Kontoinhaber*in {{ $line['label'] }}
</td> </td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;"> <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' }};">
{{ $accountOwner }} {{ $line['value'] }}
</td> </td>
</tr> </tr>
<tr> @endforeach
<td style="padding: 8px 12px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;"> </table>
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>
@php @if (!empty($paymentSummary['showGiroCode']))
@php
$girocodeSrc = $message->embedData($girocodeBinary, 'girocode.png', 'image/png'); $girocodeSrc = $message->embedData($girocodeBinary, 'girocode.png', 'image/png');
@endphp @endphp
<table style="margin: 20px;"> <table style="margin: 20px;">
<tr style="vertical-align: top; background-color: #eaeaea; border-spacing: 0"> <tr style="vertical-align: top; background-color: #eaeaea; border-spacing: 0">
<td style="padding: 20px; text-align: center; font-weight: bold;"> <td style="padding: 20px; text-align: center; font-weight: bold;">
<strong> <strong>
Bequem überweisen mit QR-Code Bequem überweisen mit QR-Code
@@ -63,10 +46,13 @@
</p> </p>
</td> </td>
</tr> </tr>
</table> </table>
<p> <p>
Bitte zahle immer mit dem vorgegeben Betreff, damit deine Zahlung möglichst einfach zugeordnet werden kann.<br/> 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 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. 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\EventPaymentModuleRegistry;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule; use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule; use App\EventPaymentModules\Modules\UndefinedPaymentModule;
use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -49,6 +50,12 @@ class EventPaymentModuleRegistryTest extends TestCase
$this->assertTrue($module->isConfigurationComplete(['iban' => 'DE123', 'account_owner' => 'Kasse'])); $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 public function test_defaults_are_built_from_modules(): void
{ {
$defaults = PaymentMethod::defaults(); $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'])); $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>']));
}
}