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"/>
@@ -66,10 +66,31 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->sanitizeAgainst($this->getOptions(), $config);
}
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $config
* @return array<string, string> Optionsname => Meldung
*/
public function configurationErrors(array $config): array
{
return [];
}
/**
+42 -4
View File
@@ -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<option, Meldung>`. Der Hook liefert in der Basis `[]`; ein Modul, dessen
Werte sonst erst die Bank zurückweist, prüft selbst (Lastschrift: IBAN, Gläubiger-ID, Vorlauf). Leere Felder meldet
der Hook nicht — das ist Sache der Pflichtfeld-Prüfung. Fassade: `PaymentMethod::configurationErrors()`.
- **Drei Guards, ein Maßstab (`isConfigurationComplete()`):**
- Tenant: `active` nur bei vollständiger Config (`UpdateAvailablePaymentMethodAction`, meldet die konkreten
Fehler; inaktiv speichern geht auch unfertig, als Entwurf). `CreateTenantAction` legt neue Stämme nur mit den
Zahlungsarten aktiv an, die schon vollständig sind — praktisch alle inaktiv, bis Bankdaten gepflegt sind.
- Event: `SetPaymentMethodsCommand` lehnt ab, wenn eine **zu schreibende** Event-Config (neuer Snapshot oder
Override) unvollständig ist — geprüft wird vor dem Schreiben, nichts wird halb übernommen.
- Anmeldung: `SignUpCommand` nimmt nur eine Zahlungsart an, die dem Event zugewiesen, beim Tenant aktiv und am
Event vollständig ist (`PaymentMethodRepository::isUsableForSignUp()`). Gilt auch für die Kurzanmeldung.
**Tests**, die eine Anmeldung durchspielen, müssen die Zahlungsart deshalb zuweisen — Trait
`Tests\Concerns\OffersAccountTransfer`.
- **Bankdaten-Sync:** `UpdateTenantPaymentAction` schreibt Kontoinhaber/IBAN/BIC des Stammes in die Tenant-Config von
Überweisung **und** Lastschrift (`BANK_ACCOUNT_SLUGS`); übrige Optionen bleiben stehen.
- Options-Typ `'number'` rendern beide Admin-Stellen als `<input type="number">`.
- **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
@@ -138,6 +154,27 @@ Beispiel GiroCode (nur Überweisung):
- Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
dieser Schicht — sie sind zahlartneutral.
## SEPA-Lastschrift (`SepaDirectDebitPaymentModule`, Slug `PAYMENT_SEPA_DIRECT_DEBIT`)
Keine Bankschnittstelle: mareike erzeugt eine Datei **pain.008.001.08** (ISO 20022, SEPA-Basislastschrift CORE, DK
DFÜ-Abkommen Anlage 3), die im Online-Banking hochgeladen wird. Bis dahin bleibt der Beitrag offen; danach gilt er als
ausgeglichen — unabhängig von Rücklastschriften.
- **Admin-Config:** `account_owner`/`iban`/`bic` (aus den Bankdaten des Stammes synchronisiert), `creditor_id`
(Gläubiger-ID, geprüft über `App\Support\CreditorId` — Mod 97-10 ohne Geschäftsbereichskennung; Testwert
`DE98ZZZ09999999999`), `pre_notification_days` (2–14, Default 5), `icon` (`file-signature`).
- **Bestandsdaten:** Migration `2026_10_04_140010` legt die Zahlungsart je Stamm **inaktiv** mit vorbefüllten
Bankdaten an (nur wenn schon Stämme existieren — bei Neuinstallation übernimmt der Seeder).
- **Abgestimmte Prozessentscheidungen** (für die nächsten Phasen):
- Mandat online per Checkbox im Anmeldeformular; Mandatsreferenz + Datum werden gespeichert. Nur IBANs aus EU/EWR,
dann sind ab 15.11.2026 keine (strukturierten) Adressen nötig.
- Anmeldemail zeigt einen Zeitraum: frühestens Anmeldetag + N, spätestens `registration_final_end` + N.
- Button „SEPA-Lastschriftdatei erzeugen" in der Event-Übersicht (`Overview.vue`): Einzugsdatum = heute + N
(nächster TARGET-Tag), Mail an jede*n Zahler*in mit genauem Datum, Betrag, Mandatsreferenz, Gläubiger-ID und
„Achte auf entsprechende Deckung"; Beitrag wird ausgeglichen. Datei bleibt gespeichert und erneut herunterladbar.
- **Offen:** Phase 2 (Teilnehmer-Optionen/Mandat, Pflicht-Checkbox muss `true` sein — heute gilt `false` als befüllt,
`registrationSummary()`, `getRefundData()` → `Known`), Phase 3 (Datei, Lauf-Protokoll, Mails).
## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -180,7 +217,8 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
`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/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Unit/CreditorIdTest`,
`tests/Feature/PaymentMethodConfigurationTest`, `tests/Feature/SignUpPaymentOptionsTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
@@ -3,6 +3,7 @@
namespace App\EventPaymentModules;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\SepaDirectDebitPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule;
/**
@@ -19,6 +20,7 @@ class EventPaymentModuleRegistry
private const MODULES = [
AccountTransferPaymentModule::class,
UndefinedPaymentModule::class,
SepaDirectDebitPaymentModule::class,
];
/** @var array<string, EventPaymentModule>|null Lazy gecachte Instanzen (slug => Modul). */
@@ -0,0 +1,132 @@
<?php
namespace App\EventPaymentModules\Modules;
use App\EventPaymentModules\AbstractEventPaymentModule;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\Models\PaymentMethod;
use App\Support\CreditorId;
use App\Support\Iban;
/**
* SEPA-Basislastschrift -- der Beitrag wird auf Grundlage eines Mandats vom Konto der zahlenden Person
* eingezogen.
*
* Eine Schnittstelle zur Bank gibt es nicht: mareike erzeugt eine Lastschriftdatei (pain.008), die im
* Online-Banking hochgeladen wird. Bis dahin bleibt der Beitrag offen.
*
* Das Gläubigerkonto kommt aus den Bankdaten des Stammes (wie bei der Überweisung, synchronisiert in
* {@see \App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentAction}); dazu kommen die
* Gläubiger-ID und der Vorlauf bis zum Einzug.
*/
class SepaDirectDebitPaymentModule extends AbstractEventPaymentModule
{
public const string OPTION_CREDITOR_ID = 'creditor_id';
public const string OPTION_PRE_NOTIFICATION_DAYS = 'pre_notification_days';
/**
* Untergrenze des Vorlaufs: ein Bankarbeitstag, den die Bank für eine Basislastschrift braucht,
* plus der Tag, an dem die Datei hochgeladen wird.
*/
public const int MIN_PRE_NOTIFICATION_DAYS = 2;
/** Obergrenze: die gesetzliche Standardfrist der Vorabankündigung -- mehr verkürzt nichts mehr. */
public const int MAX_PRE_NOTIFICATION_DAYS = 14;
public static function slug(): string
{
return PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT;
}
public function defaultName(): string
{
return 'SEPA-Lastschrift';
}
public function defaultDescription(): ?string
{
return 'Der Beitrag wird auf Grundlage eines SEPA-Lastschriftmandats vom angegebenen Konto eingezogen.';
}
public function defaultConfiguration(): array
{
return [
'icon' => 'file-signature',
self::OPTION_PRE_NOTIFICATION_DAYS => 5,
];
}
public function getOptions(): array
{
$fromTenant = 'Wird aus den Bankdaten des Stammes übernommen.';
return [
['name' => 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true, 'hint' => $fromTenant],
['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true, 'hint' => $fromTenant],
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false, 'hint' => $fromTenant],
[
'name' => self::OPTION_CREDITOR_ID,
'label' => 'Gläubiger-ID',
'type' => 'string',
'required' => true,
'hint' => 'Die Gläubiger-Identifikationsnummer wird kostenlos bei der Deutschen Bundesbank beantragt '
. '(glaeubiger-id.bundesbank.de), z. B. DE98ZZZ09999999999.',
],
[
'name' => self::OPTION_PRE_NOTIFICATION_DAYS,
'label' => 'Vorlauf bis zum Einzug (Tage)',
'type' => 'number',
'required' => true,
'hint' => sprintf(
'So viele Tage nach dem Erzeugen der Lastschriftdatei wird eingezogen (%d bis %d). '
. 'Die Frist steht als verkürzte Vorabankündigung im Mandatstext.',
self::MIN_PRE_NOTIFICATION_DAYS,
self::MAX_PRE_NOTIFICATION_DAYS,
),
],
['name' => 'icon', 'label' => 'Symbol', 'type' => 'icon', 'required' => false],
];
}
/**
* Ein Fehler in diesen Werten fiele sonst erst der Bank auf -- beim Hochladen der Datei, wenn die
* Vorabankündigungen schon verschickt sind.
*/
public function configurationErrors(array $config): array
{
$errors = [];
$iban = (string) ($config['iban'] ?? '');
if ($iban !== '' && !Iban::isValid($iban)) {
$errors['iban'] = 'Die IBAN ist ungültig.';
}
$creditorId = (string) ($config[self::OPTION_CREDITOR_ID] ?? '');
if ($creditorId !== '' && !CreditorId::isValid($creditorId)) {
$errors[self::OPTION_CREDITOR_ID] = 'Die Gläubiger-ID ist ungültig.';
}
$days = $config[self::OPTION_PRE_NOTIFICATION_DAYS] ?? '';
if ($days !== '' && $days !== null) {
$valid = filter_var($days, FILTER_VALIDATE_INT, ['options' => [
'min_range' => self::MIN_PRE_NOTIFICATION_DAYS,
'max_range' => self::MAX_PRE_NOTIFICATION_DAYS,
]]);
if ($valid === false) {
$errors[self::OPTION_PRE_NOTIFICATION_DAYS] = sprintf(
'Der Vorlauf muss eine ganze Zahl von %d bis %d Tagen sein.',
self::MIN_PRE_NOTIFICATION_DAYS,
self::MAX_PRE_NOTIFICATION_DAYS,
);
}
}
return $errors;
}
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
{
return sprintf('Der Betrag von %s wird per SEPA-Lastschrift eingezogen.', $request->amount->toString());
}
}
+13 -1
View File
@@ -23,6 +23,7 @@ class PaymentMethod extends CommonModel
public const string PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION';
public const string PAYMENT_NOT_DEFINED = 'PAYMENT_NOT_DEFINED';
public const string PAYMENT_SEPA_DIRECT_DEBIT = 'PAYMENT_SEPA_DIRECT_DEBIT';
protected $fillable = [
'slug',
@@ -105,7 +106,7 @@ class PaymentMethod extends CommonModel
}
/**
* Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind.
* Prüft, ob alle Pflicht-Optionen eines Slugs befüllt (non-empty) und alle Werte gültig sind.
* Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder.
*
* @param array<string, mixed> $config
@@ -114,4 +115,15 @@ class PaymentMethod extends CommonModel
{
return EventPaymentModuleRegistry::forSlug($slug)?->isConfigurationComplete($config) ?? true;
}
/**
* Ungültige Werte einer Konfiguration (Optionsname => Meldung), etwa eine falsche Prüfziffer.
*
* @param array<string, mixed> $config
* @return array<string, string>
*/
public static function configurationErrors(string $slug, array $config): array
{
return EventPaymentModuleRegistry::forSlug($slug)?->configurationErrors($config) ?? [];
}
}
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Repositories;
use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\PaymentMethod;
/**
* Zugriff auf die Zahlungsmethoden-Instanzen des Mandanten.
@@ -24,4 +26,22 @@ class PaymentMethodRepository
{
return (array) (AvailablePaymentMethod::where('slug', $slug)->first()?->configuration ?? []);
}
/**
* Darf bei dieser Veranstaltung mit dieser Zahlungsart angemeldet werden?
*
* Nur wenn sie der Veranstaltung zugewiesen ist, beim Mandanten aktiv ist und ihre Config am Event
* vollständig und gültig ist. Sonst ließe sich über einen manipulierten Request eine Zahlungsart
* wählen, für die die Angaben zur Zahlung fehlen -- bei der Lastschrift etwa die Gläubiger-ID.
*/
public function isUsableForSignUp(Event $event, string $slug): bool
{
$method = $event->paymentMethods()->where('available_payment_methods.slug', $slug)->first();
if ($method === null || !$method->active) {
return false;
}
return PaymentMethod::isConfigurationComplete($slug, (array) ($method->pivot->configuration ?? []));
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Zustandslose Helfer für die SEPA-Gläubiger-Identifikationsnummer (Creditor Identifier).
*
* Aufbau nach EPC262-08: Ländercode, zwei Prüfziffern, drei Zeichen Geschäftsbereichskennung, dann die
* nationale Kennung. In Deutschland vergibt die Bundesbank sie mit 18 Zeichen (`DE98ZZZ09999999999`).
*
* Die Prüfziffer ist der Teil, auf den es ankommt: Eine Lastschriftdatei mit falscher Gläubiger-ID lehnt
* die Bank als Ganzes ab -- und zwar erst beim Hochladen, wenn die Vorabankündigungen schon verschickt
* sind.
*/
final class CreditorId
{
/** Länge der Gläubiger-ID je Ländercode, soweit sie national festgelegt ist. */
private const array LENGTHS = [
'DE' => 18,
];
/** Leerzeichen raus, Großbuchstaben -- die kanonische Form, die gespeichert wird. */
public static function normalize(string $creditorId): string
{
return strtoupper(preg_replace('/\s+/', '', $creditorId) ?? '');
}
public static function isValid(string $creditorId): bool
{
$creditorId = self::normalize($creditorId);
if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{3}[A-Z0-9]{1,28}$/', $creditorId) !== 1) {
return false;
}
$expected = self::LENGTHS[substr($creditorId, 0, 2)] ?? null;
if ($expected !== null && strlen($creditorId) !== $expected) {
return false;
}
return self::checksum($creditorId) === 1;
}
/**
* Mod 97-10 wie bei der IBAN, aber ohne die Geschäftsbereichskennung (Stellen 5 bis 7): Sie darf
* der Gläubiger frei wählen, ohne dass sich die Prüfziffer ändert. Geprüft wird deshalb die nationale
* Kennung, gefolgt von Ländercode und Prüfziffern; Buchstaben zählen als Position + 9 (A = 10 … Z = 35).
*/
private static function checksum(string $creditorId): int
{
$rearranged = substr($creditorId, 7) . substr($creditorId, 0, 4);
$remainder = 0;
foreach (str_split($rearranged) as $char) {
$value = ctype_digit($char) ? $char : (string) (ord($char) - 55);
foreach (str_split($value) as $digit) {
$remainder = ($remainder * 10 + (int) $digit) % 97;
}
}
return $remainder;
}
}