From d511dbdd662261732b09c63bd5ec7a7cdda0813c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Sun, 4 Oct 2026 16:47:15 +0200
Subject: [PATCH] Handling SEPA Direct Payment
---
.../CreateTenant/CreateTenantAction.php | 8 +-
.../UpdateAvailablePaymentMethodAction.php | 9 +-
.../UpdateTenantPaymentAction.php | 20 ++-
.../Views/Partials/TenantPaymentMethods.vue | 2 +
.../SetPaymentMethodsCommand.php | 104 ++++++++++----
.../Event/Actions/SignUp/SignUpCommand.php | 9 ++
.../Views/Partials/ParticipationFees.vue | 3 +
.../AbstractEventPaymentModule.php | 25 +++-
app/EventPaymentModules/CLAUDE.md | 46 +++++-
.../EventPaymentModuleRegistry.php | 2 +
.../Modules/SepaDirectDebitPaymentModule.php | 132 ++++++++++++++++++
app/Models/PaymentMethod.php | 14 +-
app/Repositories/PaymentMethodRepository.php | 20 +++
app/Support/CreditorId.php | 66 +++++++++
...0_add_sepa_direct_debit_payment_method.php | 73 ++++++++++
resources/js/constants/paymentMethodIcons.js | 1 +
tests/Concerns/OffersAccountTransfer.php | 35 +++++
tests/Feature/InvoiceNumberingTest.php | 4 +
.../PaymentMethodConfigurationTest.php | 130 +++++++++++++++++
tests/Feature/ShortSignUpTest.php | 4 +
tests/Feature/SignUpEatingHabitTest.php | 3 +
tests/Feature/SignUpPaymentOptionsTest.php | 36 +++++
tests/Unit/CreditorIdTest.php | 45 ++++++
tests/Unit/EventPaymentModuleRegistryTest.php | 51 +++++++
version | 2 +-
25 files changed, 801 insertions(+), 43 deletions(-)
create mode 100644 app/EventPaymentModules/Modules/SepaDirectDebitPaymentModule.php
create mode 100644 app/Support/CreditorId.php
create mode 100644 database/migrations/2026_10_04_140010_add_sepa_direct_debit_payment_method.php
create mode 100644 tests/Concerns/OffersAccountTransfer.php
create mode 100644 tests/Unit/CreditorIdTest.php
diff --git a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php
index f236926..80d1e78 100644
--- a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php
+++ b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php
@@ -42,14 +42,18 @@ class CreateTenantAction
$paymentMethodDefaults = PaymentMethod::defaults();
foreach (PaymentMethod::all() as $paymentMethod) {
$defaults = $paymentMethodDefaults[$paymentMethod->slug] ?? ['name' => $paymentMethod->slug, 'description' => null, 'configuration' => []];
+ $configuration = PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $defaults['configuration'] ?? []);
+ // Aktiv nur, was schon vollständig konfiguriert ist -- derselbe Maßstab wie beim Aktivieren
+ // von Hand. Ein neuer Stamm hat noch keine Bankdaten; seine Zahlungsarten schaltet er frei,
+ // sobald sie gepflegt sind.
AvailablePaymentMethod::create([
'tenant' => $tenant->slug,
'slug' => $paymentMethod->slug,
'name' => $defaults['name'],
'description' => $defaults['description'],
- 'active' => true,
- 'configuration' => PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $defaults['configuration'] ?? []),
+ 'active' => PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration),
+ 'configuration' => $configuration,
]);
}
diff --git a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php
index 80b2d28..7a61379 100644
--- a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php
+++ b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php
@@ -20,10 +20,15 @@ class UpdateAvailablePaymentMethodAction
// Nur bekannte Options-Keys übernehmen -- das Options-Schema des Zahlungsmoduls ist die Autorität.
$configuration = PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $this->request->configuration);
- // Guard: Aktivierung nur erlaubt, wenn alle Pflicht-Optionen befüllt sind.
+ // Guard: Aktivierung nur erlaubt, wenn alle Pflicht-Optionen befüllt und gültig sind. Inaktiv
+ // gespeichert werden darf auch eine unfertige Konfiguration -- als Entwurf.
if ($this->request->active && !PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration)) {
+ $errors = PaymentMethod::configurationErrors($paymentMethod->slug, $configuration);
+
$response->success = false;
- $response->message = 'Bitte zuerst alle Pflichtfelder ausfüllen, bevor die Zahlungsmethode aktiviert wird.';
+ $response->message = $errors !== []
+ ? implode(' ', $errors) . ' Die Zahlungsmethode kann so nicht aktiviert werden.'
+ : 'Bitte zuerst alle Pflichtfelder ausfüllen, bevor die Zahlungsmethode aktiviert wird.';
return $response;
}
diff --git a/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php b/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php
index 343e883..aad04ef 100644
--- a/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php
+++ b/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php
@@ -8,6 +8,12 @@ use App\Scopes\SiteScope;
class UpdateTenantPaymentAction
{
+ /** Zahlungsarten, deren Konto das Konto des Stammes ist (Überweisung: Ziel, Lastschrift: Gläubiger). */
+ private const array BANK_ACCOUNT_SLUGS = [
+ PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
+ PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT,
+ ];
+
public function __construct(private UpdateTenantPaymentRequest $request)
{
}
@@ -22,7 +28,9 @@ class UpdateTenantPaymentAction
'account_name' => $this->request->accountName,
]);
- $this->syncTransferPaymentConfiguration();
+ foreach (self::BANK_ACCOUNT_SLUGS as $slug) {
+ $this->syncPaymentConfiguration($slug);
+ }
$response->success = true;
$response->message = 'IBAN-Informationen wurden gespeichert.';
@@ -31,15 +39,13 @@ class UpdateTenantPaymentAction
}
/**
- * Übernimmt die eingegebenen IBAN-Daten direkt in die Tenant-Config der Überweisungs-Zahlungsart
+ * Übernimmt die eingegebenen IBAN-Daten direkt in die Tenant-Config der Zahlungsart
* (Kontoinhaber/IBAN/BIC), damit sie nicht doppelt gepflegt werden müssen. Bestehende weitere
- * Config-Werte (z.B. Symbol) bleiben erhalten. Der Ziel-Tenant kann ein verwalteter sein, daher
- * ohne SiteScope explizit auf den Tenant-Slug gefiltert.
+ * Config-Werte (z.B. Symbol, Gläubiger-ID) bleiben erhalten. Der Ziel-Tenant kann ein verwalteter
+ * sein, daher ohne SiteScope explizit auf den Tenant-Slug gefiltert.
*/
- private function syncTransferPaymentConfiguration(): void
+ private function syncPaymentConfiguration(string $slug): void
{
- $slug = PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
-
$method = AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)
->where('tenant', $this->request->tenant->slug)
->where('slug', $slug)
diff --git a/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue b/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue
index a660416..6a8d3c6 100644
--- a/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue
+++ b/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue
@@ -107,6 +107,8 @@ async function save() {
v-model="form.configuration[option.name]"
:defaults="data.statementRulesetDefault ?? {}"
:charsets="data.statementRulesetCharsets ?? undefined"/>
+
{{ option.hint }}
diff --git a/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php b/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php
index 941c352..80d6a2b 100644
--- a/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php
+++ b/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php
@@ -2,9 +2,11 @@
namespace App\Domains\Event\Actions\SetPaymentMethods;
+use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod;
use App\RelationModels\EventPaymentMethods;
+use Illuminate\Support\Collection;
class SetPaymentMethodsCommand
{
@@ -25,7 +27,22 @@ class SetPaymentMethodsCommand
return $response;
}
- $this->syncPaymentMethods();
+ $existing = EventPaymentMethods::where('event_id', $this->request->event->id)->get()->keyBy('slug');
+ $configurations = $this->configurationsToWrite($existing);
+
+ // Erst prüfen, dann schreiben: Eine Zahlungsart, die am Event unvollständig konfiguriert wäre,
+ // ließe sich bei der Anmeldung wählen, ohne dass die Angaben für die Zahlung vorliegen.
+ $incomplete = $this->incompleteNames($configurations);
+ if ($incomplete !== []) {
+ $response->success = false;
+ $response->message = sprintf(
+ 'Die Konfiguration von „%s" ist unvollständig oder ungültig.',
+ implode('", „', $incomplete),
+ );
+ return $response;
+ }
+
+ $this->syncPaymentMethods($existing, $configurations);
$this->request->event->save();
$response->success = true;
@@ -34,48 +51,87 @@ class SetPaymentMethodsCommand
}
/**
- * Differenzieller Sync der Zahlungsmethoden mit Snapshot-Copy-Semantik:
- * - entfernte Methoden werden gelöscht,
+ * Die Configs, die geschrieben werden (Snapshot-Copy-Semantik):
* - neue Methoden erhalten einen Snapshot der Tenant-Config (oder eines expliziten Overrides),
- * - bestehende Methoden behalten ihre pro-Event-Config, sofern kein Override übergeben wird.
+ * - bestehende Methoden behalten ihre pro-Event-Config, sofern kein Override übergeben wird --
+ * sie tauchen hier dann nicht auf.
+ *
+ * @param Collection $existing
+ * @return array> slug => Config
*/
- private function syncPaymentMethods(): void
+ private function configurationsToWrite(Collection $existing): array
{
- $event = $this->request->event;
$desiredSlugs = $this->request->paymentMethods;
$overrides = $this->request->paymentMethodConfigurations;
- $existing = EventPaymentMethods::where('event_id', $event->id)->get()->keyBy('slug');
-
- // Nicht mehr gewünschte Methoden entfernen.
- foreach ($existing as $slug => $row) {
- if (!in_array($slug, $desiredSlugs, true)) {
- $row->delete();
- }
- }
-
// Tenant-Instanzen (für Snapshot-Kopie neuer Methoden) laden.
$tenantMethods = AvailablePaymentMethod::whereIn('slug', $desiredSlugs)->get()->keyBy('slug');
+ $configurations = [];
foreach ($desiredSlugs as $slug) {
$override = $overrides[$slug] ?? null;
- if (isset($existing[$slug])) {
- // Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
- if ($override !== null) {
- $row = $existing[$slug];
- $row->configuration = $this->eventConfiguration($slug, $override);
- $row->save();
- }
+ if (isset($existing[$slug]) && $override === null) {
continue;
}
- // Neue Zuweisung: expliziter Override oder Snapshot der Tenant-Config.
$snapshot = $override ?? ($tenantMethods[$slug]->configuration ?? []);
+ $configurations[$slug] = $this->eventConfiguration($slug, $snapshot ?? []);
+ }
+
+ return $configurations;
+ }
+
+ /**
+ * Namen der Zahlungsarten, deren Config nicht vollständig oder nicht gültig ist.
+ *
+ * Geprüft wird auf der Event-Config, also ohne die tenant-weiten Optionen -- die sind ohnehin nie
+ * Pflicht, weil sie am Event gar nicht liegen.
+ *
+ * @param array> $configurations
+ * @return array
+ */
+ private function incompleteNames(array $configurations): array
+ {
+ $names = [];
+ foreach ($configurations as $slug => $configuration) {
+ if (!PaymentMethod::isConfigurationComplete($slug, $configuration)) {
+ $names[] = EventPaymentModuleRegistry::forSlug($slug)?->defaultName() ?? $slug;
+ }
+ }
+
+ return $names;
+ }
+
+ /**
+ * Differenzieller Sync: entfernte Methoden werden gelöscht, die vorbereiteten Configs geschrieben.
+ *
+ * @param Collection $existing
+ * @param array> $configurations
+ */
+ private function syncPaymentMethods(Collection $existing, array $configurations): void
+ {
+ $event = $this->request->event;
+
+ // Nicht mehr gewünschte Methoden entfernen.
+ foreach ($existing as $slug => $row) {
+ if (!in_array($slug, $this->request->paymentMethods, true)) {
+ $row->delete();
+ }
+ }
+
+ foreach ($configurations as $slug => $configuration) {
+ if (isset($existing[$slug])) {
+ $row = $existing[$slug];
+ $row->configuration = $configuration;
+ $row->save();
+ continue;
+ }
+
EventPaymentMethods::create([
'event_id' => $event->id,
'slug' => $slug,
- 'configuration' => $this->eventConfiguration($slug, $snapshot ?? []),
+ 'configuration' => $configuration,
]);
}
}
diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
index 467c197..08202ee 100644
--- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php
+++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\EventPaymentModuleRegistry;
+use App\Repositories\PaymentMethodRepository;
use App\ValueObjects\Age;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -17,6 +18,14 @@ class SignUpCommand {
public function execute() : SignUpResponse {
$response = new SignUpResponse();
+ // Nur eine Zahlungsart, die am Event hängt, aktiv und vollständig konfiguriert ist.
+ if ($this->request->paymentMethod !== null
+ && !new PaymentMethodRepository()->isUsableForSignUp($this->request->event, $this->request->paymentMethod)) {
+ $response->success = false;
+ $response->message = 'Die gewählte Zahlungsart steht für diese Veranstaltung nicht zur Verfügung.';
+ return $response;
+ }
+
// Teilnehmer-Eingaben der gewählten Zahlungsart gegen das Modul-Schema absichern.
$module = $this->request->paymentMethod !== null
? EventPaymentModuleRegistry::forSlug($this->request->paymentMethod)
diff --git a/app/Domains/Event/Views/Partials/ParticipationFees.vue b/app/Domains/Event/Views/Partials/ParticipationFees.vue
index 0fe5fb6..9c17d8d 100644
--- a/app/Domains/Event/Views/Partials/ParticipationFees.vue
+++ b/app/Domains/Event/Views/Partials/ParticipationFees.vue
@@ -401,6 +401,9 @@ onMounted(async () => {
placeholder="Symbol wählen…"/>
+
diff --git a/app/EventPaymentModules/AbstractEventPaymentModule.php b/app/EventPaymentModules/AbstractEventPaymentModule.php
index e4f5dba..e56a525 100644
--- a/app/EventPaymentModules/AbstractEventPaymentModule.php
+++ b/app/EventPaymentModules/AbstractEventPaymentModule.php
@@ -66,10 +66,31 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->sanitizeAgainst($this->getOptions(), $config);
}
- /** @param array $config */
+ /**
+ * Vollständig heißt: alle Pflichtfelder befüllt **und** keine ungültigen Werte. Erst dann darf die
+ * Zahlungsart aktiv werden bzw. für Anmeldungen genutzt werden.
+ *
+ * @param array $config
+ */
public function isConfigurationComplete(array $config): bool
{
- return $this->allRequiredFilled($this->getOptions(), $config);
+ return $this->allRequiredFilled($this->getOptions(), $config)
+ && $this->configurationErrors($config) === [];
+ }
+
+ /**
+ * Inhaltliche Prüfung der Admin-Config über das reine „befüllt" hinaus (z.B. Prüfziffer einer
+ * IBAN). Leere Felder meldet hier niemand -- das ist Sache der Pflichtfeld-Prüfung.
+ *
+ * Standard: keine Prüfung. Bestandsmodule bleiben so, wie sie sind; eine Zahlungsart, bei der ein
+ * ungültiger Wert erst die Bank zurückweist (Lastschrift), prüft selbst.
+ *
+ * @param array $config
+ * @return array Optionsname => Meldung
+ */
+ public function configurationErrors(array $config): array
+ {
+ return [];
}
/**
diff --git a/app/EventPaymentModules/CLAUDE.md b/app/EventPaymentModules/CLAUDE.md
index 19ae5a2..8f6fa7d 100644
--- a/app/EventPaymentModules/CLAUDE.md
+++ b/app/EventPaymentModules/CLAUDE.md
@@ -11,7 +11,8 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
- `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).
+ `AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges),
+ `SepaDirectDebitPaymentModule` (SEPA-Lastschrift, im Aufbau — siehe unten).
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
`GetRefundData*`, `TransactionMatch`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
@@ -44,8 +45,23 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
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`.
+- **Vollständig = befüllt + gültig:** `isConfigurationComplete()` verlangt alle `required`-Optionen **und** ein leeres
+ `configurationErrors(array $config): array