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
@@ -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());
}
}