Moved bank transfer logic to special module
This commit is contained in:
@@ -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">
|
||||
<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> </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> </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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
<p>
|
||||
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>
|
||||
da andernfalls die Stornierung deiner Anmeldung erfolgen kann.
|
||||
@if ($paymentSummary['hasPaymentInformation'])
|
||||
@if ($paymentSummary['showGiroCode'])
|
||||
<p>
|
||||
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>
|
||||
da andernfalls die Stornierung deiner Anmeldung erfolgen kann.
|
||||
|
||||
Um deine Anmeldung zu vervollständigen, überweise den Betrag bitte zugunsten folgender Bankverbindung:
|
||||
</p>
|
||||
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,72 +1,58 @@
|
||||
<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;">
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; width: 180px; font-weight: 600; color: #4b5563; border-bottom: 1px solid #e5e7eb;">
|
||||
Kontoinhaber*in
|
||||
</td>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
|
||||
{{ $accountOwner }}
|
||||
</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>
|
||||
{{-- 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: {{ $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: {{ $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>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
@php
|
||||
$girocodeSrc = $message->embedData($girocodeBinary, 'girocode.png', 'image/png');
|
||||
@endphp
|
||||
<table style="margin: 20px;">
|
||||
<tr style="vertical-align: top; background-color: #eaeaea; border-spacing: 0">
|
||||
@if (!empty($paymentSummary['showGiroCode']))
|
||||
@php
|
||||
$girocodeSrc = $message->embedData($girocodeBinary, 'girocode.png', 'image/png');
|
||||
@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
|
||||
</strong><br/><br/>
|
||||
|
||||
<td style="padding: 20px; text-align: center; font-weight: bold;">
|
||||
<strong>
|
||||
Bequem überweisen mit QR-Code
|
||||
</strong><br/><br/>
|
||||
<img
|
||||
style="padding-left: 20px;width: 150px; height: 150px;"
|
||||
src="{{ $girocodeSrc }}"
|
||||
alt="Giro-Code">
|
||||
</td>
|
||||
<td>
|
||||
<p style="padding-left:10px; padding-right: 10px; text-align: left;">
|
||||
<strong>Und so funktioniert es</strong><br/>
|
||||
<ul style="text-align: left; list-style: decimal; font-weight: normal">
|
||||
<li style="padding-right: 10px;">Mache einen Screenshot des QR-Codes</li>
|
||||
<li style="padding-right: 10px;">Öffne deine Banking-App und wähle den Menüpunkt
|
||||
„(Foto-)Überweisung“.
|
||||
</li>
|
||||
<li style="padding-right: 10px;">Wähle das gespeicherte Bild aus</li>
|
||||
</ul>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<img
|
||||
style="padding-left: 20px;width: 150px; height: 150px;"
|
||||
src="{{ $girocodeSrc }}"
|
||||
alt="Giro-Code">
|
||||
</td>
|
||||
<td>
|
||||
<p style="padding-left:10px; padding-right: 10px; text-align: left;">
|
||||
<strong>Und so funktioniert es</strong><br/>
|
||||
<ul style="text-align: left; list-style: decimal; font-weight: normal">
|
||||
<li style="padding-right: 10px;">Mache einen Screenshot des QR-Codes</li>
|
||||
<li style="padding-right: 10px;">Öffne deine Banking-App und wähle den Menüpunkt
|
||||
„(Foto-)Überweisung“.
|
||||
</li>
|
||||
<li style="padding-right: 10px;">Wähle das gespeicherte Bild aus</li>
|
||||
</ul>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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>
|
||||
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>
|
||||
@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();
|
||||
|
||||
@@ -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', []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user