190 lines
15 KiB
Markdown
190 lines
15 KiB
Markdown
# 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()`, `getRefundData()`.
|
|
- `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*`,
|
|
`GetRefundData*`, `TransactionMatch`).
|
|
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
|
|
hier eintragen.** Kein Container-Binding.
|
|
- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements` — **Fähigkeits-Interfaces** (siehe unten).
|
|
|
|
## Kernregeln
|
|
|
|
- **Zwei getrennte Options-Schemata (beide im Code, gleiche Form `['name','label','type','required']`):**
|
|
- `getOptions()` = **Admin-Config** (was der/die Veranstalter\*in pflegt, z.B. Empfänger-Konto). Werte in der DB
|
|
(`configuration`, s.u.).
|
|
- `getParticipantOptions()` = **Teilnehmer-Eingaben** beim Anmelden (payer-seitig, z.B. künftig
|
|
SEPA-IBAN/PayPal-Mail; beide Bestandsmodule: `[]`). Werte in `event_participants.payment_options` (JSON).
|
|
Abgesichert über
|
|
`sanitizeParticipantOptions()` / `participantOptionsComplete()` (Guard im `SignUpCommand`).
|
|
- `type` ist i.d.R. `'string'`; `'richtext'` wird über `Views/Components/TextEditor.vue` (TinyMCE, HTML) gerendert
|
|
und via `v-html`/`{!! !!}` ausgegeben; `'icon'` rendert die `Views/Components/RichSelectBox.vue` (kuratierte
|
|
FA-Symbol-Auswahl, Liste in `resources/js/constants/paymentMethodIcons.js`). Teilnehmer-Eingaben rendert die
|
|
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben.
|
|
- Optionaler Schlüssel `'hint'` je Option: erklärender Hilfetext, den die Admin-Render-Stellen unter dem Feld anzeigen
|
|
(z.B. bei `payment_information`, dass der Text am Anmeldeende + in der Mail erscheint).
|
|
- Optionaler Schlüssel `'scope' => 'tenant'` (nur `getOptions()`): Die Option gilt für den **ganzen Mandanten** und
|
|
wird **nicht** pro Event eingefroren. Abgeleitet über `tenantScopedOptionKeys()` /
|
|
`stripTenantScopedOptions()`, angewandt beim Copy-on-Assign in `SetPaymentMethodsCommand` und ausgefiltert in
|
|
`ParticipationFees.vue`. Einziger Fall: `statement_ruleset` (s.u.).
|
|
- Optionaler Schlüssel `'system' => true` (nur `getParticipantOptions()`): Das Feld liegt zwar in
|
|
`payment_options`, wird aber **nicht im Anmeldeformular abgefragt** — es entsteht im Programm. Gefiltert wird
|
|
serverseitig in `AbstractEventPaymentModule::participantInputOptions()`, an das `PaymentMethod::
|
|
participantOptionsFor()` (und damit die Resource/das Frontend) delegiert. Im Schema müssen die Felder trotzdem
|
|
stehen, sonst verwirft `sanitizeParticipantOptions()` sie als unbekannte Schlüssel.
|
|
- `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
|
|
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`.
|
|
- **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
|
|
`SetPaymentMethodsCommand` (Endpoint `/api/v1/event/details/{event}/payment-methods`, ausgelöst aus dem
|
|
„Teilnahmegebühren"-Bereich).
|
|
- **`PaymentMethod` ist nur eine Fassade:** `PaymentMethod::optionsFor/participantOptionsFor/requiredOptionKeys/`
|
|
`sanitizeConfiguration/isConfigurationComplete/defaults()` delegieren an die Registry. Bestehende Aufrufstellen
|
|
bleiben stabil.
|
|
- **Zahlungsauswahl im Anmeldeprozess:** Nach „Allergien" wählt der/die Teilnehmer\*in aus den **aktiven**
|
|
Event-Methoden (`StepPaymentMethod.vue`); bei Beitrag 0 € wird der Schritt übersprungen. Die Wahl landet in
|
|
`event_participants.payment_method`, die Eingaben in `payment_options`. Der Überweisungs-Bestätigungstext in der
|
|
Zusammenfassung ist eine Admin-Config-Option `summary_confirmation_text` (`{amount}`-Platzhalter) und erscheint nur
|
|
bei
|
|
`PAYMENT_ACCOUNT_TRANSACTION`.
|
|
- **`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.
|
|
- **`getRefundData()` — „auf welches Konto wäre zu erstatten, und woher kommt es?"** Steht im **Kern-Interface**, weil
|
|
jede Zahlungsart eine Antwort darauf hat; sie fällt nur unterschiedlich aus. Geantwortet wird mit
|
|
`App\Enumerations\RefundAccountSource` (reines Code-Enum, nirgends gespeichert):
|
|
- `Known` — das Konto liegt vor. Nur die Überweisung liefert das, aus `payment_options`
|
|
(`payer_iban`/`payer_account_owner`, vom Kontoauszug-Import hinterlegt) und **nur bei gültiger Prüfziffer**; eine
|
|
ungültige IBAN würde ungeprüft übernommen. `event_participants.refund_data` ist demgegenüber nur ein
|
|
**abgeleitetes Kennzeichen** für Listen und Abfragen, nie die Quelle — zwei Quellen für dieselbe Wahrheit driften
|
|
auseinander.
|
|
- `Origin` — es gab ein Ursprungskonto, wir kennen es nicht. **Vorgabe der Basisklasse**, bewusst die strengere
|
|
Annahme: Der Teili wird gefragt, ob es dasselbe Konto ist, und bestätigt die Herkunft. Das ist die Kontrolle
|
|
gegen das Umleiten einer Erstattung auf ein fremdes Konto; sie stillschweigend fallen zu lassen wäre die falsche
|
|
Vorgabe für ein künftiges Modul.
|
|
- `None` — es gab **nie** eines (`UndefinedPaymentModule`, Barzahlung). Herkunftsfrage und Herkunfts-Erklärung
|
|
wären sinnlos bzw. unwahr; an ihre Stelle tritt `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN` („läuft auf meinen
|
|
Namen"). Welcher `page_texts`-Eintrag gilt, sagt `RefundAccountSource::accountDeclarationText()` — eine Quelle
|
|
für Seite **und** Beleg.
|
|
Aufgelöst wird überall über `EventParticipant::refundData()`; verwertet in `ReleaseRefundCommand` (schreibt ein
|
|
bekanntes Konto direkt an den Vorgang, der aber `pending` bleibt — der Teili entscheidet noch über Auszahlung oder
|
|
Spende), in `AcceptRefundCommand` (ein gesetztes Konto lässt sich **nicht** aus dem Request überschreiben), im
|
|
`RefundPageController` und im Erstattungsbeleg. Auf der Token-Seite und in der Freigabe-Mail geht eine bekannte IBAN
|
|
nur maskiert hinaus (`Iban::mask()`).
|
|
- **Keine Barauszahlung.** Auch wer bar gezahlt hat, bekommt überwiesen. Rechtlich spricht nichts dagegen — das GwG
|
|
gilt für den Verband nicht (§ 2 Abs. 1 GwG; kein Güterhändler nach § 1 Abs. 9), und eine Regel „bar rein, bar raus"
|
|
existiert nicht. Die Überweisung ist zudem besser belegt: Der Kontoauszug beweist die Zahlung, während eine
|
|
Barauszahlung an einer Unterschrift hinge und die Barkasse nach § 146 AO kassensturzfähig zu halten wäre. Wer doch
|
|
bar auszahlt, bucht das über die normale Auslagenerfassung.
|
|
|
|
## 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.**
|
|
|
|
### Kontoauszug-Import (zwei Interfaces, bewusst getrennt)
|
|
|
|
- `ProvidesStatementRuleset::statementRuleset(array $configuration): BankStatementRuleset` — „dieses Modul pflegt das
|
|
CSV-Format der Bank". Implementiert **nur** von `AccountTransferPaymentModule`. Das Format ist eine Eigenschaft der
|
|
**Bank**, nicht der Zahlungsart: eine Bank, ein Export, ein Ruleset. Das kommende Lastschrift-Modul liest denselben
|
|
Auszug und implementiert dieses Interface **nicht** — sonst wäre dasselbe Format zweimal zu pflegen und nach dem
|
|
nächsten Bankwechsel eine der beiden Stellen vergessen.
|
|
- `ReadsBankStatements` — „dieses Modul kann Umsätze verwerten": `isRelevantTransaction()` (Überweisung: Gutschriften;
|
|
Lastschrift später: Belastungen und Rücklastschriften), `matchTransaction()` (Überweisung: Verwendungszweck, Namen,
|
|
bekannte Zahler-IBAN, Betrag; Lastschrift später: Mandatsreferenz), `recordTransaction()` (Überweisung: Zahler-Konto
|
|
in `payment_options` + `refund_data`). Nur diese drei Entscheidungen sind zahlartspezifisch.
|
|
- **Ablage des Rulesets:** App-Standard in `config/bankStatement.php` (GLS Gemeinschaftsbank), Tenant-Override in der
|
|
Modul-Option `statement_ruleset` (`type: 'bank-ruleset'`, `scope: 'tenant'`). Der Override gilt **ganz oder gar
|
|
nicht** — kein feldweiser Merge, sonst bekäme man beim Umstellen des Trennzeichens weiterhin die Spaltennamen der
|
|
GLS untergeschoben.
|
|
- **Kandidaten schließen Abgemeldete ein** (`EventParticipantRepository::getForPaymentMatching()`): Wer den Beitrag
|
|
überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Die Zahlung wird erfasst — erst dann gibt
|
|
es etwas zu erstatten. Die Prüfansicht weist die Abmeldung aus (`isSignedOff`/`signedOffAt`).
|
|
- **Modul-Schicht bleibt DB-frei:** Die Konfiguration wird hereingereicht, die Kandidaten für `matchTransaction()`
|
|
ebenfalls. Geholt wird beides vom `PaymentMethodRepository` bzw. `EventParticipantRepository`, verdrahtet in den
|
|
Actions `ParseBankStatement` / `BookBankStatementPayments` (Domain `Event`). `doPayment()` ist **nicht** beteiligt:
|
|
das stößt eine Zahlung an, hier wird eine bereits erfolgte nachgetragen.
|
|
- Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
|
|
dieser Schicht — sie sind zahlartneutral.
|
|
|
|
## Anmelde-Zusammenfassung / Mail-Anzeige
|
|
|
|
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
|
|
Anzeige-Block **fertig als HTML** (`hasPaymentInformation` + `html`). Jedes Modul rendert seinen Block über
|
|
eigene Blade-Templates unter `resources/views/payment-modules/{modul}/{web,mail}.blade.php` (Überweisung:
|
|
`account-transfer/`, Barzahlung: `undefined/` — Rahmen um den konfigurierten richtext). Die Render-Stellen geben
|
|
nur noch `v-html` / `{!! !!}` aus — **keine** zahlart-spezifische Logik im Frontend/Blade.
|
|
- **Render-Kontext:** `RegistrationSummaryRequest` trägt einen `RegistrationRenderContext` (`Web`/`Mail`). Das Modul
|
|
wählt darüber (a) ein **medien-gerechtes Template** — `resources/views/payment-modules/{modul}/{web,mail}.blade.php`
|
|
(Web: SPA-Klassen `form-table`/`link`; Mail: E-Mail-taugliche Inline-Styles) und (b) die GiroCode-Bildquelle —
|
|
Web: `/print-girocode/{token}` (sessionlos, lazy), Mail: `cid:girocode.png` (inline via `$message->embedData(...)`,
|
|
bereitgestellt im dünnen Wrapper `emails/subparts/payment.blade.php`).
|
|
Hinweis: Das Web-Template darf globale (un-scoped) SPA-Klassen nutzen, da Vue-`scoped`-Styles nicht auf `v-html`
|
|
greifen.
|
|
- Zugang über das Teilnehmer-Model: `EventParticipant::paymentModule()`, `paymentConfiguration()` (eager-load-fähig über
|
|
`->with('event.paymentMethods')`, kein statischer Cache), `paymentSummary(RegistrationRenderContext)`.
|
|
- Konsumiert von `EventParticipantResource` (`paymentSummary` = `{hasPaymentInformation, html}`, Web-Kontext),
|
|
`SubmitSuccess.vue`, den Mails (`EventSignUpSuccessfullMail`/`ParticipantPaymentMissingPaymentMail` rufen
|
|
`paymentSummary(Mail)`), sowie `signup_complete`/`missing_amount` (nur umrahmende Prosa, gegated auf
|
|
`hasPaymentInformation`).
|
|
|
|
## Neues Zahlungsmodul hinzufügen
|
|
|
|
1. Klasse unter `Modules/` anlegen, `extends AbstractEventPaymentModule`; `slug()`, `defaultName()`, `getOptions()`
|
|
implementieren; `defaultConfiguration()` (z.B. Default-`icon`) sowie
|
|
`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/2026_08_05_backfill_payment_method_configuration.sql` überführt einmalig Bestands-IBAN-Daten (Tenant/Event)
|
|
**und** die Default-Symbole in die Modul-Config (idempotenter JSON-Merge, nichts wird überschrieben). Bei
|
|
Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt das ältere PHP-Skript
|
|
`sync_payment_bank_data.php`.
|
|
|
|
## Tests
|
|
|
|
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
|
|
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
|
|
`tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
|
|
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
|
|
`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
|
|
|
|
Ausführung im Container (PHP 8.5). `php artisan test` läuft im 128-MB-Limit auf `config/postCode.php` in einen
|
|
Speicherfehler, deshalb direkt über PHPUnit mit angehobenem Limit:
|
|
`docker exec mareike-mareike-app-1 php -d memory_limit=1G vendor/bin/phpunit`
|