Files
mareike/app/EventPaymentModules/CLAUDE.md
T

15 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

  • EventPaymentModuleCore-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, ReadsBankStatementsFä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:
    $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 Templateresources/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