Handling SEPA Direct Payment

This commit is contained in:
2026-10-04 16:47:15 +02:00
parent 9e9ea0cfd5
commit d511dbdd66
25 changed files with 801 additions and 43 deletions
@@ -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,
]);
}
@@ -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;
}
@@ -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)
@@ -107,6 +107,8 @@ async function save() {
v-model="form.configuration[option.name]"
:defaults="data.statementRulesetDefault ?? {}"
:charsets="data.statementRulesetCharsets ?? undefined"/>
<input v-else-if="option.type === 'number'" type="number" step="1"
v-model="form.configuration[option.name]" class="form-input"/>
<input v-else type="text" v-model="form.configuration[option.name]" class="form-input"/>
<span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td>
@@ -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<string, EventPaymentMethods> $existing
* @return array<string, array<string, mixed>> 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<string, array<string, mixed>> $configurations
* @return array<int, string>
*/
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<string, EventPaymentMethods> $existing
* @param array<string, array<string, mixed>> $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,
]);
}
}
@@ -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)
@@ -401,6 +401,9 @@ onMounted(async () => {
placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"/>
<input v-else-if="option.type === 'number'" type="number" step="1"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
class="width-full"/>
<input v-else type="text"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
class="width-full"/>