Handling SEPA Direct Payment
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user