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(); $paymentMethodDefaults = PaymentMethod::defaults();
foreach (PaymentMethod::all() as $paymentMethod) { foreach (PaymentMethod::all() as $paymentMethod) {
$defaults = $paymentMethodDefaults[$paymentMethod->slug] ?? ['name' => $paymentMethod->slug, 'description' => null, 'configuration' => []]; $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([ AvailablePaymentMethod::create([
'tenant' => $tenant->slug, 'tenant' => $tenant->slug,
'slug' => $paymentMethod->slug, 'slug' => $paymentMethod->slug,
'name' => $defaults['name'], 'name' => $defaults['name'],
'description' => $defaults['description'], 'description' => $defaults['description'],
'active' => true, 'active' => PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration),
'configuration' => PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $defaults['configuration'] ?? []), 'configuration' => $configuration,
]); ]);
} }
@@ -20,10 +20,15 @@ class UpdateAvailablePaymentMethodAction
// Nur bekannte Options-Keys übernehmen -- das Options-Schema des Zahlungsmoduls ist die Autorität. // Nur bekannte Options-Keys übernehmen -- das Options-Schema des Zahlungsmoduls ist die Autorität.
$configuration = PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $this->request->configuration); $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)) { if ($this->request->active && !PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration)) {
$errors = PaymentMethod::configurationErrors($paymentMethod->slug, $configuration);
$response->success = false; $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; return $response;
} }
@@ -8,6 +8,12 @@ use App\Scopes\SiteScope;
class UpdateTenantPaymentAction 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) public function __construct(private UpdateTenantPaymentRequest $request)
{ {
} }
@@ -22,7 +28,9 @@ class UpdateTenantPaymentAction
'account_name' => $this->request->accountName, 'account_name' => $this->request->accountName,
]); ]);
$this->syncTransferPaymentConfiguration(); foreach (self::BANK_ACCOUNT_SLUGS as $slug) {
$this->syncPaymentConfiguration($slug);
}
$response->success = true; $response->success = true;
$response->message = 'IBAN-Informationen wurden gespeichert.'; $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 * (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 * Config-Werte (z.B. Symbol, Gläubiger-ID) bleiben erhalten. Der Ziel-Tenant kann ein verwalteter
* ohne SiteScope explizit auf den Tenant-Slug gefiltert. * 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) $method = AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)
->where('tenant', $this->request->tenant->slug) ->where('tenant', $this->request->tenant->slug)
->where('slug', $slug) ->where('slug', $slug)
@@ -107,6 +107,8 @@ async function save() {
v-model="form.configuration[option.name]" v-model="form.configuration[option.name]"
:defaults="data.statementRulesetDefault ?? {}" :defaults="data.statementRulesetDefault ?? {}"
:charsets="data.statementRulesetCharsets ?? undefined"/> :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"/> <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> <span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td> </td>
@@ -2,9 +2,11 @@
namespace App\Domains\Event\Actions\SetPaymentMethods; namespace App\Domains\Event\Actions\SetPaymentMethods;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\RelationModels\EventPaymentMethods; use App\RelationModels\EventPaymentMethods;
use Illuminate\Support\Collection;
class SetPaymentMethodsCommand class SetPaymentMethodsCommand
{ {
@@ -25,7 +27,22 @@ class SetPaymentMethodsCommand
return $response; 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(); $this->request->event->save();
$response->success = true; $response->success = true;
@@ -34,48 +51,87 @@ class SetPaymentMethodsCommand
} }
/** /**
* Differenzieller Sync der Zahlungsmethoden mit Snapshot-Copy-Semantik: * Die Configs, die geschrieben werden (Snapshot-Copy-Semantik):
* - entfernte Methoden werden gelöscht,
* - neue Methoden erhalten einen Snapshot der Tenant-Config (oder eines expliziten Overrides), * - 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; $desiredSlugs = $this->request->paymentMethods;
$overrides = $this->request->paymentMethodConfigurations; $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. // Tenant-Instanzen (für Snapshot-Kopie neuer Methoden) laden.
$tenantMethods = AvailablePaymentMethod::whereIn('slug', $desiredSlugs)->get()->keyBy('slug'); $tenantMethods = AvailablePaymentMethod::whereIn('slug', $desiredSlugs)->get()->keyBy('slug');
$configurations = [];
foreach ($desiredSlugs as $slug) { foreach ($desiredSlugs as $slug) {
$override = $overrides[$slug] ?? null; $override = $overrides[$slug] ?? null;
if (isset($existing[$slug])) { if (isset($existing[$slug]) && $override === null) {
// 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();
}
continue; continue;
} }
// Neue Zuweisung: expliziter Override oder Snapshot der Tenant-Config.
$snapshot = $override ?? ($tenantMethods[$slug]->configuration ?? []); $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([ EventPaymentMethods::create([
'event_id' => $event->id, 'event_id' => $event->id,
'slug' => $slug, 'slug' => $slug,
'configuration' => $this->eventConfiguration($slug, $snapshot ?? []), 'configuration' => $configuration,
]); ]);
} }
} }
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus; use App\Enumerations\EfzStatus;
use App\Enumerations\SwimmingPermission; use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\EventPaymentModuleRegistry; use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\Repositories\PaymentMethodRepository;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -17,6 +18,14 @@ class SignUpCommand {
public function execute() : SignUpResponse { public function execute() : SignUpResponse {
$response = new 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. // Teilnehmer-Eingaben der gewählten Zahlungsart gegen das Modul-Schema absichern.
$module = $this->request->paymentMethod !== null $module = $this->request->paymentMethod !== null
? EventPaymentModuleRegistry::forSlug($this->request->paymentMethod) ? EventPaymentModuleRegistry::forSlug($this->request->paymentMethod)
@@ -401,6 +401,9 @@ onMounted(async () => {
placeholder="Symbol wählen…"/> placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'" <TextEditor v-else-if="option.type === 'richtext'"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"/> 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" <input v-else type="text"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]" v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
class="width-full"/> class="width-full"/>
@@ -66,10 +66,31 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->sanitizeAgainst($this->getOptions(), $config); 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 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 - `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies. (`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart): - `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*`, - `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
`GetRefundData*`, `TransactionMatch`). `GetRefundData*`, `TransactionMatch`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module - `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. stehen, sonst verwirft `sanitizeParticipantOptions()` sie als unbekannte Schlüssel.
- `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`), - `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`. 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 - **Vollständig = befüllt + gültig:** `isConfigurationComplete()` verlangt alle `required`-Optionen **und** ein leeres
sind (`isConfigurationComplete()`), erzwungen in `UpdateAvailablePaymentMethodAction`. `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 - **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** `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 (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 - Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
dieser Schicht — sie sind zahlartneutral. 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 ## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen - `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/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`, `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/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`. `tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
@@ -3,6 +3,7 @@
namespace App\EventPaymentModules; namespace App\EventPaymentModules;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule; use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\SepaDirectDebitPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule; use App\EventPaymentModules\Modules\UndefinedPaymentModule;
/** /**
@@ -19,6 +20,7 @@ class EventPaymentModuleRegistry
private const MODULES = [ private const MODULES = [
AccountTransferPaymentModule::class, AccountTransferPaymentModule::class,
UndefinedPaymentModule::class, UndefinedPaymentModule::class,
SepaDirectDebitPaymentModule::class,
]; ];
/** @var array<string, EventPaymentModule>|null Lazy gecachte Instanzen (slug => Modul). */ /** @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_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION';
public const string PAYMENT_NOT_DEFINED = 'PAYMENT_NOT_DEFINED'; public const string PAYMENT_NOT_DEFINED = 'PAYMENT_NOT_DEFINED';
public const string PAYMENT_SEPA_DIRECT_DEBIT = 'PAYMENT_SEPA_DIRECT_DEBIT';
protected $fillable = [ protected $fillable = [
'slug', '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. * Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder.
* *
* @param array<string, mixed> $config * @param array<string, mixed> $config
@@ -114,4 +115,15 @@ class PaymentMethod extends CommonModel
{ {
return EventPaymentModuleRegistry::forSlug($slug)?->isConfigurationComplete($config) ?? true; 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; namespace App\Repositories;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\PaymentMethod;
/** /**
* Zugriff auf die Zahlungsmethoden-Instanzen des Mandanten. * Zugriff auf die Zahlungsmethoden-Instanzen des Mandanten.
@@ -24,4 +26,22 @@ class PaymentMethodRepository
{ {
return (array) (AvailablePaymentMethod::where('slug', $slug)->first()?->configuration ?? []); 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;
}
}
@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Zahlungsart SEPA-Lastschrift für bestehende Installationen.
*
* Eine Neuinstallation legt `payment_methods` über den ProductionDataSeeder an (er iteriert die
* Modul-Registry) und neue Stämme ihre Instanzen über CreateTenantAction. Beides läuft auf einer
* bestehenden Datenbank nicht noch einmal -- ohne diese Migration gäbe es die Zahlungsart dort nicht.
*
* Gibt es noch keinen Stamm, ist es eine frische Installation: Dann bleibt alles dem Seeder überlassen,
* der sonst an der bereits vorhandenen Zeile scheitern würde.
*
* Jeder Stamm bekommt die Zahlungsart **inaktiv**, vorbefüllt mit seinen Bankdaten. Aktiv wird sie erst,
* wenn eine gültige Gläubiger-ID gepflegt ist (Guard in UpdateAvailablePaymentMethodAction).
*/
return new class extends Migration {
private const string SLUG = 'PAYMENT_SEPA_DIRECT_DEBIT';
public function up(): void
{
$tenants = DB::table('tenants')->get(['slug', 'account_name', 'account_iban', 'account_bic']);
if ($tenants->isEmpty()) {
return;
}
if (!DB::table('payment_methods')->where('slug', self::SLUG)->exists()) {
DB::table('payment_methods')->insert([
'id' => (string) Str::uuid(),
'slug' => self::SLUG,
'created_at' => now(),
'updated_at' => now(),
]);
}
foreach ($tenants as $tenant) {
$exists = DB::table('available_payment_methods')
->where('tenant', $tenant->slug)
->where('slug', self::SLUG)
->exists();
if ($exists) {
continue;
}
DB::table('available_payment_methods')->insert([
'tenant' => $tenant->slug,
'slug' => self::SLUG,
'name' => 'SEPA-Lastschrift',
'description' => 'Der Beitrag wird auf Grundlage eines SEPA-Lastschriftmandats vom angegebenen Konto eingezogen.',
'active' => false,
'configuration' => json_encode([
'account_owner' => (string) $tenant->account_name,
'iban' => (string) $tenant->account_iban,
'bic' => (string) $tenant->account_bic,
'pre_notification_days' => 5,
'icon' => 'file-signature',
]),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
DB::table('available_payment_methods')->where('slug', self::SLUG)->delete();
DB::table('payment_methods')->where('slug', self::SLUG)->delete();
}
};
@@ -3,6 +3,7 @@
// beim Build gebündelt (kein Netzwerk-Load). value === icon (FA-Solid-Name im kebab-case). // beim Build gebündelt (kein Netzwerk-Load). value === icon (FA-Solid-Name im kebab-case).
export const PAYMENT_METHOD_ICONS = [ export const PAYMENT_METHOD_ICONS = [
{value: 'building-columns', label: 'Bank / Überweisung', icon: 'building-columns'}, {value: 'building-columns', label: 'Bank / Überweisung', icon: 'building-columns'},
{value: 'file-signature', label: 'Lastschrift / Mandat', icon: 'file-signature'},
{value: 'money-bill-wave', label: 'Bargeld', icon: 'money-bill-wave'}, {value: 'money-bill-wave', label: 'Bargeld', icon: 'money-bill-wave'},
{value: 'credit-card', label: 'Kreditkarte', icon: 'credit-card'}, {value: 'credit-card', label: 'Kreditkarte', icon: 'credit-card'},
{value: 'wallet', label: 'Geldbörse', icon: 'wallet'}, {value: 'wallet', label: 'Geldbörse', icon: 'wallet'},
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Tests\Concerns;
use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\PaymentMethod;
use App\RelationModels\EventPaymentMethods;
use App\Scopes\SiteScope;
/**
* Bietet bei einer Veranstaltung die Überweisung an -- aktiv beim Mandanten und vollständig konfiguriert.
*
* Der SignUpCommand nimmt nur eine Zahlungsart an, die der Veranstaltung zugewiesen ist; ein Test, der
* eine Anmeldung durchspielt, muss sie deshalb so anlegen, wie es der Betrieb tut. Wiederholte Aufrufe
* schaden nicht. Die Zeile in `payment_methods` legt der Test selbst an.
*/
trait OffersAccountTransfer
{
protected function offerAccountTransfer(Event $event): void
{
$slug = PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)->firstOrCreate(
['tenant' => $event->tenant, 'slug' => $slug],
['name' => 'Überweisung', 'active' => true, 'configuration' => $configuration],
);
EventPaymentMethods::firstOrCreate(
['event_id' => $event->id, 'slug' => $slug],
['configuration' => $configuration],
);
}
}
+4
View File
@@ -19,6 +19,7 @@ use App\ValueObjects\Amount;
use DateTime; use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\Concerns\OffersAccountTransfer;
use Tests\TestCase; use Tests\TestCase;
/** /**
@@ -28,6 +29,7 @@ use Tests\TestCase;
class InvoiceNumberingTest extends TestCase class InvoiceNumberingTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
use OffersAccountTransfer;
private Tenant $wildeMoehre; private Tenant $wildeMoehre;
@@ -164,6 +166,8 @@ class InvoiceNumberingTest extends TestCase
private function signUp(Event $event, string $firstname): void private function signUp(Event $event, string $firstname): void
{ {
$this->offerAccountTransfer($event);
$response = new SignUpCommand(new SignUpRequest( $response = new SignUpCommand(new SignUpRequest(
$event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
$this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt', $this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
@@ -2,14 +2,21 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Domains\Admin\Actions\CreateTenant\CreateTenantAction;
use App\Domains\Admin\Actions\CreateTenant\CreateTenantRequest;
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodAction; use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodAction;
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodRequest; use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodRequest;
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodResponse;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentAction;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentRequest;
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsCommand; use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsCommand;
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsRequest; use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsRequest;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Models\Event; use App\Models\Event;
use App\Models\ParentGroup;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Models\Tenant; use App\Models\Tenant;
use App\Scopes\SiteScope;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\TestCase; use Tests\TestCase;
@@ -43,6 +50,7 @@ class PaymentMethodConfigurationTest extends TestCase
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT]);
DB::table('participation_fee_types')->insert([ DB::table('participation_fee_types')->insert([
'slug' => 'FIXED', 'slug' => 'FIXED',
@@ -127,6 +135,128 @@ class PaymentMethodConfigurationTest extends TestCase
$this->assertSame('DE-EVENT', $event->fresh()->paymentMethods()->first()->pivot->configuration['iban']); $this->assertSame('DE-EVENT', $event->fresh()->paymentMethods()->first()->pivot->configuration['iban']);
} }
public function test_sepa_activation_reports_invalid_values(): void
{
$method = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT);
$config = $this->validSepaConfiguration();
foreach ([
'creditor_id' => ['', 'Pflichtfelder'],
'iban' => ['DE02120300000000202015', 'IBAN ist ungültig'],
'pre_notification_days' => ['1', 'Vorlauf'],
] as $key => [$value, $message]) {
$response = $this->updateMethod($method, true, array_merge($config, [$key => $value]));
$this->assertFalse($response->success, $key);
$this->assertStringContainsString($message, $response->message, $key);
$this->assertFalse($method->fresh()->active, $key);
}
$response = $this->updateMethod($method, true, array_merge($config, ['creditor_id' => 'DE97ZZZ09999999999']));
$this->assertStringContainsString('Gläubiger-ID ist ungültig', $response->message);
}
public function test_sepa_can_be_saved_as_draft_and_activated_once_valid(): void
{
$method = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT);
// Inaktiv darf auch eine unfertige Konfiguration gespeichert werden.
$draft = $this->updateMethod($method, false, ['creditor_id' => 'unfertig']);
$this->assertTrue($draft->success);
$this->assertSame('unfertig', $method->fresh()->configuration['creditor_id']);
$response = $this->updateMethod($method->fresh(), true, $this->validSepaConfiguration());
$this->assertTrue($response->success, $response->message);
$this->assertTrue($method->fresh()->active);
}
public function test_tenant_bank_data_is_synced_into_transfer_and_direct_debit(): void
{
$transfer = $this->availableMethod(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, config: ['icon' => 'coins']);
$directDebit = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, config: [
'creditor_id' => 'DE98ZZZ09999999999',
'pre_notification_days' => 7,
]);
new UpdateTenantPaymentAction(new UpdateTenantPaymentRequest(
$this->tenant, 'DE02120300000000202051', 'BYLADEM1001', 'Stamm e.V.',
))->execute();
$this->assertSame('DE02120300000000202051', $transfer->fresh()->configuration['iban']);
$this->assertSame('coins', $transfer->fresh()->configuration['icon']);
$config = $directDebit->fresh()->configuration;
$this->assertSame('Stamm e.V.', $config['account_owner']);
$this->assertSame('DE02120300000000202051', $config['iban']);
$this->assertSame('BYLADEM1001', $config['bic']);
// Gläubiger-ID und Vorlauf gehören nicht zu den Bankdaten und bleiben stehen.
$this->assertSame('DE98ZZZ09999999999', $config['creditor_id']);
$this->assertSame(7, $config['pre_notification_days']);
}
public function test_event_assignment_rejects_an_incomplete_configuration(): void
{
$this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, true, $this->validSepaConfiguration());
$event = $this->createEvent();
$response = new SetPaymentMethodsCommand(new SetPaymentMethodsRequest(
$event,
[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT],
[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT => array_merge($this->validSepaConfiguration(), ['creditor_id' => 'DE97ZZZ09999999999'])],
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('SEPA-Lastschrift', $response->message);
$this->assertSame(0, $event->paymentMethods()->count()); // nichts geschrieben
// Ohne Override greift der (gültige) Snapshot der Tenant-Config.
$this->runUpdateEvent($event, [PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT]);
$this->assertSame(
'DE98ZZZ09999999999',
$event->fresh()->paymentMethods()->first()->pivot->configuration['creditor_id'],
);
}
public function test_new_tenant_gets_payment_methods_inactive_until_configured(): void
{
$parentGroup =ParentGroup::where('is_fallback', true)->first()
?? ParentGroup::create(['name' => 'Ohne Zuordnung', 'is_fallback' => true]);
$response = new CreateTenantAction(new CreateTenantRequest(
name: 'Neu', slug: 'neu', url: 'neu.local', parentGroup: $parentGroup,
))->execute();
$this->assertTrue($response->success, $response->message);
$methods = AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)->where('tenant', 'neu')->get();
$this->assertCount(3, $methods);
$this->assertTrue($methods->every(fn (AvailablePaymentMethod $method) => !$method->active));
}
/** @return array<string, mixed> */
private function validSepaConfiguration(): array
{
return [
'account_owner' => 'Test e.V.',
'iban' => 'DE02120300000000202051',
'bic' => '',
'creditor_id' => 'DE98ZZZ09999999999',
'pre_notification_days' => '5',
];
}
/** @param array<string, mixed> $configuration */
private function updateMethod(AvailablePaymentMethod $method, bool $active, array $configuration): UpdateAvailablePaymentMethodResponse
{
return new UpdateAvailablePaymentMethodAction(new UpdateAvailablePaymentMethodRequest(
availablePaymentMethod: $method,
name: 'SEPA-Lastschrift',
description: null,
active: $active,
configuration: $configuration,
))->execute();
}
private function createEvent(): Event private function createEvent(): Event
{ {
return Event::create([ return Event::create([
+4
View File
@@ -22,11 +22,13 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Tests\Concerns\OffersAccountTransfer;
use Tests\TestCase; use Tests\TestCase;
class ShortSignUpTest extends TestCase class ShortSignUpTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
use OffersAccountTransfer;
private Tenant $tenant; private Tenant $tenant;
@@ -84,6 +86,8 @@ class ShortSignUpTest extends TestCase
$event->refresh(); $event->refresh();
} }
$this->offerAccountTransfer($event);
return $event; return $event;
} }
+3
View File
@@ -15,6 +15,7 @@ use App\Models\Tenant;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\Concerns\OffersAccountTransfer;
use Tests\TestCase; use Tests\TestCase;
/** /**
@@ -24,6 +25,7 @@ use Tests\TestCase;
class SignUpEatingHabitTest extends TestCase class SignUpEatingHabitTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
use OffersAccountTransfer;
private Tenant $tenant; private Tenant $tenant;
@@ -60,6 +62,7 @@ class SignUpEatingHabitTest extends TestCase
'pay_per_day' => false, 'pay_direct' => false, 'pay_per_day' => false, 'pay_direct' => false,
]); ]);
$event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all()); $event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all());
$this->offerAccountTransfer($event);
return $event; return $event;
} }
@@ -13,15 +13,18 @@ use App\Models\AvailablePaymentMethod;
use App\Models\Event; use App\Models\Event;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Models\Tenant; use App\Models\Tenant;
use App\RelationModels\EventPaymentMethods;
use App\Resources\AvailablePaymentMethodResource; use App\Resources\AvailablePaymentMethodResource;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\Concerns\OffersAccountTransfer;
use Tests\TestCase; use Tests\TestCase;
class SignUpPaymentOptionsTest extends TestCase class SignUpPaymentOptionsTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
use OffersAccountTransfer;
private Tenant $tenant; private Tenant $tenant;
@@ -75,6 +78,7 @@ class SignUpPaymentOptionsTest extends TestCase
public function test_payment_options_are_sanitized_and_stored(): void public function test_payment_options_are_sanitized_and_stored(): void
{ {
$event = $this->createEvent(); $event = $this->createEvent();
$this->offerAccountTransfer($event);
// Überweisung hat keine Teilnehmer-Eingaben -> alles wird verworfen, payment_options bleibt leer. // Überweisung hat keine Teilnehmer-Eingaben -> alles wird verworfen, payment_options bleibt leer.
$response = new SignUpCommand($this->signUpRequest($event, ['evil' => 'x']))->execute(); $response = new SignUpCommand($this->signUpRequest($event, ['evil' => 'x']))->execute();
@@ -85,6 +89,38 @@ class SignUpPaymentOptionsTest extends TestCase
$this->assertStringContainsString('Beitrag Hans Muster', $response->participant->payment_purpose); $this->assertStringContainsString('Beitrag Hans Muster', $response->participant->payment_purpose);
} }
public function test_sign_up_is_rejected_for_a_payment_method_not_offered_by_the_event(): void
{
$event = $this->createEvent();
AvailablePaymentMethod::create([
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'name' => 'Überweisung', 'active' => true, 'configuration' => ['account_owner' => 'Kasse', 'iban' => 'DE1'],
]);
// Beim Mandanten aktiv, aber der Veranstaltung nicht zugewiesen.
$response = new SignUpCommand($this->signUpRequest($event, []))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, $event->participants()->count());
}
public function test_sign_up_is_rejected_when_the_method_is_inactive_or_incomplete(): void
{
$event = $this->createEvent();
$this->offerAccountTransfer($event);
// Zugewiesen, aber am Event unvollständig konfiguriert (z.B. per Override ohne IBAN).
EventPaymentMethods::where('event_id', $event->id)->update(['configuration' => json_encode(['account_owner' => 'Kasse'])]);
$this->assertFalse(new SignUpCommand($this->signUpRequest($event, []))->execute()->success);
// Vollständig, aber beim Mandanten deaktiviert.
EventPaymentMethods::where('event_id', $event->id)->update(['configuration' => json_encode(['account_owner' => 'Kasse', 'iban' => 'DE1'])]);
AvailablePaymentMethod::where('slug', PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION)->update(['active' => false]);
$this->assertFalse(new SignUpCommand($this->signUpRequest($event, []))->execute()->success);
$this->assertSame(0, $event->participants()->count());
}
public function test_resource_exposes_participant_options_schema(): void public function test_resource_exposes_participant_options_schema(): void
{ {
$event = $this->createEvent(); $event = $this->createEvent();
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use App\Support\CreditorId;
use PHPUnit\Framework\TestCase;
class CreditorIdTest extends TestCase
{
public function test_normalizes_spaces_and_case(): void
{
$this->assertSame('DE98ZZZ09999999999', CreditorId::normalize(' de98 zzz 0999 9999 999 '));
}
public function test_accepts_the_bundesbank_test_id(): void
{
$this->assertTrue(CreditorId::isValid('DE98ZZZ09999999999'));
$this->assertTrue(CreditorId::isValid('de98 zzz 09999999999'));
}
public function test_business_code_does_not_affect_the_check_digit(): void
{
// Die Geschäftsbereichskennung (Stellen 5-7) wählt der Gläubiger selbst -- die Prüfziffer bleibt.
$this->assertTrue(CreditorId::isValid('DE98ABC09999999999'));
}
public function test_rejects_a_wrong_check_digit(): void
{
$this->assertFalse(CreditorId::isValid('DE97ZZZ09999999999'));
$this->assertFalse(CreditorId::isValid('DE98ZZZ09999999998'));
}
public function test_rejects_a_wrong_german_length(): void
{
$this->assertFalse(CreditorId::isValid('DE98ZZZ0999999999'));
$this->assertFalse(CreditorId::isValid('DE98ZZZ099999999990'));
}
public function test_rejects_malformed_input(): void
{
$this->assertFalse(CreditorId::isValid(''));
$this->assertFalse(CreditorId::isValid('DE02120300000000202051')); // eine IBAN
$this->assertFalse(CreditorId::isValid('DEXXZZZ09999999999'));
}
}
@@ -4,6 +4,7 @@ namespace Tests\Unit;
use App\EventPaymentModules\EventPaymentModuleRegistry; use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule; use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\EventPaymentModules\Modules\SepaDirectDebitPaymentModule;
use App\EventPaymentModules\Modules\UndefinedPaymentModule; use App\EventPaymentModules\Modules\UndefinedPaymentModule;
use App\EventPaymentModules\ProvidesGiroCode; use App\EventPaymentModules\ProvidesGiroCode;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
@@ -21,6 +22,55 @@ class EventPaymentModuleRegistryTest extends TestCase
UndefinedPaymentModule::class, UndefinedPaymentModule::class,
EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_NOT_DEFINED) EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_NOT_DEFINED)
); );
$this->assertInstanceOf(
SepaDirectDebitPaymentModule::class,
EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT)
);
}
public function test_sepa_direct_debit_requires_a_valid_configuration(): void
{
$module = new SepaDirectDebitPaymentModule();
$valid = [
'account_owner' => 'Test e.V.',
'iban' => 'DE02120300000000202051',
'creditor_id' => 'DE98ZZZ09999999999',
'pre_notification_days' => '5',
];
$this->assertSame(['account_owner', 'iban', 'creditor_id', 'pre_notification_days'], $module->requiredOptionKeys());
$this->assertSame([], $module->configurationErrors($valid));
$this->assertTrue($module->isConfigurationComplete($valid));
// Befüllt, aber ungültig -- reicht nicht.
foreach ([
'iban' => 'DE02120300000000202015',
'creditor_id' => 'DE97ZZZ09999999999',
'pre_notification_days' => '1',
] as $key => $value) {
$config = array_merge($valid, [$key => $value]);
$this->assertArrayHasKey($key, $module->configurationErrors($config), $key);
$this->assertFalse($module->isConfigurationComplete($config), $key);
}
foreach (['15', '2.5', 'fünf'] as $days) {
$this->assertArrayHasKey('pre_notification_days', $module->configurationErrors(
array_merge($valid, ['pre_notification_days' => $days])
), $days);
}
// Leere Felder sind Sache der Pflichtfeld-Prüfung, nicht der Inhaltsprüfung.
$this->assertSame([], $module->configurationErrors([]));
$this->assertFalse($module->isConfigurationComplete(array_merge($valid, ['creditor_id' => ''])));
}
public function test_sepa_direct_debit_defaults(): void
{
$defaults = PaymentMethod::defaults()[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT];
$this->assertSame('SEPA-Lastschrift', $defaults['name']);
$this->assertSame(5, $defaults['configuration']['pre_notification_days']);
$this->assertSame('file-signature', $defaults['configuration']['icon']);
} }
public function test_for_slug_returns_null_for_unknown(): void public function test_for_slug_returns_null_for_unknown(): void
@@ -34,6 +84,7 @@ class EventPaymentModuleRegistryTest extends TestCase
$this->assertContains(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $slugs); $this->assertContains(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $slugs);
$this->assertContains(PaymentMethod::PAYMENT_NOT_DEFINED, $slugs); $this->assertContains(PaymentMethod::PAYMENT_NOT_DEFINED, $slugs);
$this->assertContains(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, $slugs);
} }
public function test_module_option_helpers(): void public function test_module_option_helpers(): void
+1 -1
View File
@@ -1 +1 @@
4.10.3 4.11.0