From d511dbdd662261732b09c63bd5ec7a7cdda0813c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnrher?= Date: Sun, 4 Oct 2026 16:47:15 +0200 Subject: [PATCH] Handling SEPA Direct Payment --- .../CreateTenant/CreateTenantAction.php | 8 +- .../UpdateAvailablePaymentMethodAction.php | 9 +- .../UpdateTenantPaymentAction.php | 20 ++- .../Views/Partials/TenantPaymentMethods.vue | 2 + .../SetPaymentMethodsCommand.php | 104 ++++++++++---- .../Event/Actions/SignUp/SignUpCommand.php | 9 ++ .../Views/Partials/ParticipationFees.vue | 3 + .../AbstractEventPaymentModule.php | 25 +++- app/EventPaymentModules/CLAUDE.md | 46 +++++- .../EventPaymentModuleRegistry.php | 2 + .../Modules/SepaDirectDebitPaymentModule.php | 132 ++++++++++++++++++ app/Models/PaymentMethod.php | 14 +- app/Repositories/PaymentMethodRepository.php | 20 +++ app/Support/CreditorId.php | 66 +++++++++ ...0_add_sepa_direct_debit_payment_method.php | 73 ++++++++++ resources/js/constants/paymentMethodIcons.js | 1 + tests/Concerns/OffersAccountTransfer.php | 35 +++++ tests/Feature/InvoiceNumberingTest.php | 4 + .../PaymentMethodConfigurationTest.php | 130 +++++++++++++++++ tests/Feature/ShortSignUpTest.php | 4 + tests/Feature/SignUpEatingHabitTest.php | 3 + tests/Feature/SignUpPaymentOptionsTest.php | 36 +++++ tests/Unit/CreditorIdTest.php | 45 ++++++ tests/Unit/EventPaymentModuleRegistryTest.php | 51 +++++++ version | 2 +- 25 files changed, 801 insertions(+), 43 deletions(-) create mode 100644 app/EventPaymentModules/Modules/SepaDirectDebitPaymentModule.php create mode 100644 app/Support/CreditorId.php create mode 100644 database/migrations/2026_10_04_140010_add_sepa_direct_debit_payment_method.php create mode 100644 tests/Concerns/OffersAccountTransfer.php create mode 100644 tests/Unit/CreditorIdTest.php diff --git a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php index f236926..80d1e78 100644 --- a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php +++ b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php @@ -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, ]); } diff --git a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php index 80b2d28..7a61379 100644 --- a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php +++ b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php @@ -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; } diff --git a/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php b/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php index 343e883..aad04ef 100644 --- a/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php +++ b/app/Domains/Admin/Actions/UpdateTenantPayment/UpdateTenantPaymentAction.php @@ -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) diff --git a/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue b/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue index a660416..6a8d3c6 100644 --- a/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue +++ b/app/Domains/Admin/Views/Partials/TenantPaymentMethods.vue @@ -107,6 +107,8 @@ async function save() { v-model="form.configuration[option.name]" :defaults="data.statementRulesetDefault ?? {}" :charsets="data.statementRulesetCharsets ?? undefined"/> + {{ option.hint }} diff --git a/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php b/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php index 941c352..80d6a2b 100644 --- a/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php +++ b/app/Domains/Event/Actions/SetPaymentMethods/SetPaymentMethodsCommand.php @@ -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 $existing + * @return array> 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> $configurations + * @return array + */ + 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 $existing + * @param array> $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, ]); } } diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php index 467c197..08202ee 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php +++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php @@ -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) diff --git a/app/Domains/Event/Views/Partials/ParticipationFees.vue b/app/Domains/Event/Views/Partials/ParticipationFees.vue index 0fe5fb6..9c17d8d 100644 --- a/app/Domains/Event/Views/Partials/ParticipationFees.vue +++ b/app/Domains/Event/Views/Partials/ParticipationFees.vue @@ -401,6 +401,9 @@ onMounted(async () => { placeholder="Symbol wählen…"/> + diff --git a/app/EventPaymentModules/AbstractEventPaymentModule.php b/app/EventPaymentModules/AbstractEventPaymentModule.php index e4f5dba..e56a525 100644 --- a/app/EventPaymentModules/AbstractEventPaymentModule.php +++ b/app/EventPaymentModules/AbstractEventPaymentModule.php @@ -66,10 +66,31 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule return $this->sanitizeAgainst($this->getOptions(), $config); } - /** @param array $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 $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 $config + * @return array Optionsname => Meldung + */ + public function configurationErrors(array $config): array + { + return []; } /** diff --git a/app/EventPaymentModules/CLAUDE.md b/app/EventPaymentModules/CLAUDE.md index 19ae5a2..8f6fa7d 100644 --- a/app/EventPaymentModules/CLAUDE.md +++ b/app/EventPaymentModules/CLAUDE.md @@ -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`. 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 ``. - **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`. diff --git a/app/EventPaymentModules/EventPaymentModuleRegistry.php b/app/EventPaymentModules/EventPaymentModuleRegistry.php index 96698ba..f6be3b3 100644 --- a/app/EventPaymentModules/EventPaymentModuleRegistry.php +++ b/app/EventPaymentModules/EventPaymentModuleRegistry.php @@ -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|null Lazy gecachte Instanzen (slug => Modul). */ diff --git a/app/EventPaymentModules/Modules/SepaDirectDebitPaymentModule.php b/app/EventPaymentModules/Modules/SepaDirectDebitPaymentModule.php new file mode 100644 index 0000000..f06d43c --- /dev/null +++ b/app/EventPaymentModules/Modules/SepaDirectDebitPaymentModule.php @@ -0,0 +1,132 @@ + '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()); + } +} diff --git a/app/Models/PaymentMethod.php b/app/Models/PaymentMethod.php index 2ff43f3..2357c75 100644 --- a/app/Models/PaymentMethod.php +++ b/app/Models/PaymentMethod.php @@ -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 $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 $config + * @return array + */ + public static function configurationErrors(string $slug, array $config): array + { + return EventPaymentModuleRegistry::forSlug($slug)?->configurationErrors($config) ?? []; + } } diff --git a/app/Repositories/PaymentMethodRepository.php b/app/Repositories/PaymentMethodRepository.php index f6e1762..87365f5 100644 --- a/app/Repositories/PaymentMethodRepository.php +++ b/app/Repositories/PaymentMethodRepository.php @@ -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 ?? [])); + } } diff --git a/app/Support/CreditorId.php b/app/Support/CreditorId.php new file mode 100644 index 0000000..9e461d7 --- /dev/null +++ b/app/Support/CreditorId.php @@ -0,0 +1,66 @@ + 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; + } +} diff --git a/database/migrations/2026_10_04_140010_add_sepa_direct_debit_payment_method.php b/database/migrations/2026_10_04_140010_add_sepa_direct_debit_payment_method.php new file mode 100644 index 0000000..a8ff9b6 --- /dev/null +++ b/database/migrations/2026_10_04_140010_add_sepa_direct_debit_payment_method.php @@ -0,0 +1,73 @@ +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(); + } +}; diff --git a/resources/js/constants/paymentMethodIcons.js b/resources/js/constants/paymentMethodIcons.js index 0bffb79..6272025 100644 --- a/resources/js/constants/paymentMethodIcons.js +++ b/resources/js/constants/paymentMethodIcons.js @@ -3,6 +3,7 @@ // beim Build gebündelt (kein Netzwerk-Load). value === icon (FA-Solid-Name im kebab-case). export const PAYMENT_METHOD_ICONS = [ {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: 'credit-card', label: 'Kreditkarte', icon: 'credit-card'}, {value: 'wallet', label: 'Geldbörse', icon: 'wallet'}, diff --git a/tests/Concerns/OffersAccountTransfer.php b/tests/Concerns/OffersAccountTransfer.php new file mode 100644 index 0000000..78075f7 --- /dev/null +++ b/tests/Concerns/OffersAccountTransfer.php @@ -0,0 +1,35 @@ + '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], + ); + } +} diff --git a/tests/Feature/InvoiceNumberingTest.php b/tests/Feature/InvoiceNumberingTest.php index 522ef64..77fa06c 100644 --- a/tests/Feature/InvoiceNumberingTest.php +++ b/tests/Feature/InvoiceNumberingTest.php @@ -19,6 +19,7 @@ use App\ValueObjects\Amount; use DateTime; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Tests\Concerns\OffersAccountTransfer; use Tests\TestCase; /** @@ -28,6 +29,7 @@ use Tests\TestCase; class InvoiceNumberingTest extends TestCase { use RefreshDatabase; + use OffersAccountTransfer; private Tenant $wildeMoehre; @@ -164,6 +166,8 @@ class InvoiceNumberingTest extends TestCase private function signUp(Event $event, string $firstname): void { + $this->offerAccountTransfer($event); + $response = new SignUpCommand(new SignUpRequest( $event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt', diff --git a/tests/Feature/PaymentMethodConfigurationTest.php b/tests/Feature/PaymentMethodConfigurationTest.php index e46f003..a16ef45 100644 --- a/tests/Feature/PaymentMethodConfigurationTest.php +++ b/tests/Feature/PaymentMethodConfigurationTest.php @@ -2,14 +2,21 @@ 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\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\SetPaymentMethodsRequest; use App\Models\AvailablePaymentMethod; use App\Models\Event; +use App\Models\ParentGroup; use App\Models\PaymentMethod; use App\Models\Tenant; +use App\Scopes\SiteScope; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -43,6 +50,7 @@ class PaymentMethodConfigurationTest extends TestCase PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT]); DB::table('participation_fee_types')->insert([ 'slug' => 'FIXED', @@ -127,6 +135,128 @@ class PaymentMethodConfigurationTest extends TestCase $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 */ + private function validSepaConfiguration(): array + { + return [ + 'account_owner' => 'Test e.V.', + 'iban' => 'DE02120300000000202051', + 'bic' => '', + 'creditor_id' => 'DE98ZZZ09999999999', + 'pre_notification_days' => '5', + ]; + } + + /** @param array $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 { return Event::create([ diff --git a/tests/Feature/ShortSignUpTest.php b/tests/Feature/ShortSignUpTest.php index a90f344..ca3586a 100644 --- a/tests/Feature/ShortSignUpTest.php +++ b/tests/Feature/ShortSignUpTest.php @@ -22,11 +22,13 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; +use Tests\Concerns\OffersAccountTransfer; use Tests\TestCase; class ShortSignUpTest extends TestCase { use RefreshDatabase; + use OffersAccountTransfer; private Tenant $tenant; @@ -84,6 +86,8 @@ class ShortSignUpTest extends TestCase $event->refresh(); } + $this->offerAccountTransfer($event); + return $event; } diff --git a/tests/Feature/SignUpEatingHabitTest.php b/tests/Feature/SignUpEatingHabitTest.php index 0cb8246..ccd19f1 100644 --- a/tests/Feature/SignUpEatingHabitTest.php +++ b/tests/Feature/SignUpEatingHabitTest.php @@ -15,6 +15,7 @@ use App\Models\Tenant; use App\ValueObjects\Amount; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Tests\Concerns\OffersAccountTransfer; use Tests\TestCase; /** @@ -24,6 +25,7 @@ use Tests\TestCase; class SignUpEatingHabitTest extends TestCase { use RefreshDatabase; + use OffersAccountTransfer; private Tenant $tenant; @@ -60,6 +62,7 @@ class SignUpEatingHabitTest extends TestCase 'pay_per_day' => false, 'pay_direct' => false, ]); $event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all()); + $this->offerAccountTransfer($event); return $event; } diff --git a/tests/Feature/SignUpPaymentOptionsTest.php b/tests/Feature/SignUpPaymentOptionsTest.php index 1466f6c..eead43e 100644 --- a/tests/Feature/SignUpPaymentOptionsTest.php +++ b/tests/Feature/SignUpPaymentOptionsTest.php @@ -13,15 +13,18 @@ use App\Models\AvailablePaymentMethod; use App\Models\Event; use App\Models\PaymentMethod; use App\Models\Tenant; +use App\RelationModels\EventPaymentMethods; use App\Resources\AvailablePaymentMethodResource; use App\ValueObjects\Amount; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Tests\Concerns\OffersAccountTransfer; use Tests\TestCase; class SignUpPaymentOptionsTest extends TestCase { use RefreshDatabase; + use OffersAccountTransfer; private Tenant $tenant; @@ -75,6 +78,7 @@ class SignUpPaymentOptionsTest extends TestCase public function test_payment_options_are_sanitized_and_stored(): void { $event = $this->createEvent(); + $this->offerAccountTransfer($event); // Überweisung hat keine Teilnehmer-Eingaben -> alles wird verworfen, payment_options bleibt leer. $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); } + 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 { $event = $this->createEvent(); diff --git a/tests/Unit/CreditorIdTest.php b/tests/Unit/CreditorIdTest.php new file mode 100644 index 0000000..e0d9e2f --- /dev/null +++ b/tests/Unit/CreditorIdTest.php @@ -0,0 +1,45 @@ +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')); + } +} diff --git a/tests/Unit/EventPaymentModuleRegistryTest.php b/tests/Unit/EventPaymentModuleRegistryTest.php index 1e4bb16..0f6a710 100644 --- a/tests/Unit/EventPaymentModuleRegistryTest.php +++ b/tests/Unit/EventPaymentModuleRegistryTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit; use App\EventPaymentModules\EventPaymentModuleRegistry; use App\EventPaymentModules\Modules\AccountTransferPaymentModule; +use App\EventPaymentModules\Modules\SepaDirectDebitPaymentModule; use App\EventPaymentModules\Modules\UndefinedPaymentModule; use App\EventPaymentModules\ProvidesGiroCode; use App\Models\PaymentMethod; @@ -21,6 +22,55 @@ class EventPaymentModuleRegistryTest extends TestCase UndefinedPaymentModule::class, 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 @@ -34,6 +84,7 @@ class EventPaymentModuleRegistryTest extends TestCase $this->assertContains(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $slugs); $this->assertContains(PaymentMethod::PAYMENT_NOT_DEFINED, $slugs); + $this->assertContains(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, $slugs); } public function test_module_option_helpers(): void diff --git a/version b/version index 8540cb1..a162ea7 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.10.3 +4.11.0