12 KiB
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 ausgetOptions()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 Mapslug → 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 inevent_participants.payment_options(JSON). Abgesichert übersanitizeParticipantOptions()/participantOptionsComplete()(Guard imSignUpCommand).typeist i.d.R.'string';'richtext'wird überViews/Components/TextEditor.vue(TinyMCE, HTML) gerendert und viav-html/{!! !!}ausgegeben;'icon'rendert dieViews/Components/RichSelectBox.vue(kuratierte FA-Symbol-Auswahl, Liste inresources/js/constants/paymentMethodIcons.js). Teilnehmer-Eingaben rendert die generischeSignUpForm/components/PaymentMethodInputs.vueschema-getrieben.- Optionaler Schlüssel
'hint'je Option: erklärender Hilfetext, den die Admin-Render-Stellen unter dem Feld anzeigen (z.B. beipayment_information, dass der Text am Anmeldeende + in der Mail erscheint). - Optionaler Schlüssel
'scope' => 'tenant'(nurgetOptions()): Die Option gilt für den ganzen Mandanten und wird nicht pro Event eingefroren. Abgeleitet übertenantScopedOptionKeys()/stripTenantScopedOptions(), angewandt beim Copy-on-Assign inSetPaymentMethodsCommandund ausgefiltert inParticipationFees.vue. Einziger Fall:statement_ruleset(s.u.). - Optionaler Schlüssel
'system' => true(nurgetParticipantOptions()): Das Feld liegt zwar inpayment_options, wird aber nicht im Anmeldeformular abgefragt — es entsteht im Programm. Gefiltert wird serverseitig inAbstractEventPaymentModule::participantInputOptions(), an dasPaymentMethod:: participantOptionsFor()(und damit die Resource/das Frontend) delegiert. Im Schema müssen die Felder trotzdem stehen, sonst verwirftsanitizeParticipantOptions()sie als unbekannte Schlüssel. defaultConfiguration()je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (CreateTenantAction), aktuell das Default-Symbol (icon): Überweisungbuilding-columns, Sonstigescoins.
- Aktivierungs-Guard: Eine Tenant-Zahlungsmethode darf nur
activewerden, wenn allerequired-Optionen befüllt sind (isConfigurationComplete()), erzwungen inUpdateAvailablePaymentMethodAction. - Config-Speicherung (JSON): pro Tenant auf
available_payment_methods.configuration, pro Event auf dem Pivotevent_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 inSetPaymentMethodsCommand(Endpoint/api/v1/event/details/{event}/payment-methods, ausgelöst aus dem „Teilnahmegebühren"-Bereich). PaymentMethodist 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 inevent_participants.payment_method, die Eingaben inpayment_options. Der Überweisungs-Bestätigungstext in der Zusammenfassung ist eine Admin-Config-Optionsummary_confirmation_text({amount}-Platzhalter) und erscheint nur beiPAYMENT_ACCOUNT_TRANSACTION. PaymentStatusist ein DB-gestütztes Enumerations-Model (app/Enumerations/PaymentStatus.php, Tabellepayment_status, geseedet inProductionDataSeeder), analog zuInvoiceStatus. Reine Steuersignale (z.B. Redirect) gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.doPayment()undcreateInvoice()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 vonAccountTransferPaymentModule.- Aufrufer (
GiroCodeGetController,EventSignUpSuccessfullMail,ParticipantPaymentMissingPaymentMail) lösen inline auf:$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.
ProvidesRedirectfür PayPal,ProvidesMandatefü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 vonAccountTransferPaymentModule. 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 inpayment_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-Optionstatement_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 vomPaymentMethodRepositorybzw.EventParticipantRepository, verdrahtet in den ActionsParseBankStatement/BookBankStatementPayments(DomainEvent).doPayment()ist nicht beteiligt: das stößt eine Zahlung an, hier wird eine bereits erfolgte nachgetragen. - Parser (
App\Providers\BankStatementParseProvider),BankStatementRulesetundBankTransactionliegen außerhalb dieser Schicht — sie sind zahlartneutral.
Anmelde-Zusammenfassung / Mail-Anzeige
registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponseliefert den zahlungsspezifischen Anzeige-Block fertig als HTML (hasPaymentInformation+html). Jedes Modul rendert seinen Block über eigene Blade-Templates unterresources/views/payment-modules/{modul}/{web,mail}.blade.php(Überweisung:account-transfer/, Barzahlung:undefined/— Rahmen um den konfigurierten richtext). Die Render-Stellen geben nur nochv-html/{!! !!}aus — keine zahlart-spezifische Logik im Frontend/Blade.- Render-Kontext:
RegistrationSummaryRequestträgt einenRegistrationRenderContext(Web/Mail). Das Modul wählt darüber (a) ein medien-gerechtes Template —resources/views/payment-modules/{modul}/{web,mail}.blade.php(Web: SPA-Klassenform-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 Wrapperemails/subparts/payment.blade.php). Hinweis: Das Web-Template darf globale (un-scoped) SPA-Klassen nutzen, da Vue-scoped-Styles nicht aufv-htmlgreifen. - 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/ParticipantPaymentMissingPaymentMailrufenpaymentSummary(Mail)), sowiesignup_complete/missing_amount(nur umrahmende Prosa, gegated aufhasPaymentInformation).
Neues Zahlungsmodul hinzufügen
- Klasse unter
Modules/anlegen,extends AbstractEventPaymentModule;slug(),defaultName(),getOptions()implementieren;defaultConfiguration()(z.B. Default-icon) sowieregistrationSummary()/doPayment()/createInvoice()überschreiben, wo nötig. - Zahlart-spezifische Extras als eigenes Fähigkeits-Interface (nicht ins Core-Interface).
- In
EventPaymentModuleRegistry::MODULESeintragen.PaymentMethod::create(['slug' => …])wird dann automatisch überProductionDataSeeder(iteriertEventPaymentModuleRegistry::slugs()) geseedet. - Slug-Konstante bei Bedarf auf
PaymentMethodergä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/Feature/PaymentMethodConfigurationTest,
tests/Feature/EventParticipantPaymentSummaryTest, tests/Feature/BankStatementImportTest.
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