Moved bank transfer logic to special module
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user