diff --git a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php index 2ea2af6..9d20ed8 100644 --- a/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php +++ b/app/Domains/Admin/Actions/CreateTenant/CreateTenantAction.php @@ -31,8 +31,9 @@ class CreateTenantAction 'has_active_instance' => true, ]); + $paymentMethodDefaults = PaymentMethod::defaults(); foreach (PaymentMethod::all() as $paymentMethod) { - $defaults = PaymentMethod::DEFAULTS[$paymentMethod->slug] ?? ['name' => $paymentMethod->slug, 'description' => null]; + $defaults = $paymentMethodDefaults[$paymentMethod->slug] ?? ['name' => $paymentMethod->slug, 'description' => null]; AvailablePaymentMethod::create([ 'tenant' => $tenant->slug, diff --git a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php index 2df18b0..80b2d28 100644 --- a/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php +++ b/app/Domains/Admin/Actions/UpdateAvailablePaymentMethod/UpdateAvailablePaymentMethodAction.php @@ -17,7 +17,7 @@ class UpdateAvailablePaymentMethodAction $paymentMethod = $this->request->availablePaymentMethod; $wasActive = $paymentMethod->active; - // Nur bekannte Options-Keys übernehmen -- das Schema (PaymentMethod::OPTIONS) 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); // Guard: Aktivierung nur erlaubt, wenn alle Pflicht-Optionen befüllt sind. diff --git a/app/Enumerations/PaymentStatus.php b/app/Enumerations/PaymentStatus.php new file mode 100644 index 0000000..9597623 --- /dev/null +++ b/app/Enumerations/PaymentStatus.php @@ -0,0 +1,24 @@ + + */ + public function requiredOptionKeys(): array + { + return array_values(array_map( + static fn (array $option) => $option['name'], + array_filter($this->getOptions(), static fn (array $option) => $option['required'] ?? false) + )); + } + + /** + * Reduziert eine Konfiguration auf die im Schema definierten Options-Keys. + * Unbekannte Keys werden verworfen -- getOptions() ist die Autorität. + * + * @param array $config + * @return array + */ + public function sanitizeConfiguration(array $config): array + { + $result = []; + foreach ($this->getOptions() as $option) { + if (array_key_exists($option['name'], $config)) { + $result[$option['name']] = $config[$option['name']]; + } + } + + return $result; + } + + /** + * Prüft, ob alle Pflicht-Optionen befüllt (non-empty) sind. + * + * @param array $config + */ + public function isConfigurationComplete(array $config): bool + { + foreach ($this->requiredOptionKeys() as $key) { + $value = $config[$key] ?? null; + if ($value === null || $value === '' || $value === []) { + return false; + } + } + + return true; + } + + // ----------------------------------------------------------------------------------------- + // Operative Methoden -- in dieser Iteration bewusst als Stubs. + // ----------------------------------------------------------------------------------------- + + public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse + { + // TODO: In einer Folge-Iteration ausformulieren (zahlungsspezifischer Teil der Zusammenfassung). + return new RegistrationSummaryResponse(); + } + + public function doPayment(DoPaymentRequest $request): DoPaymentResponse + { + // TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen). + $response = new DoPaymentResponse(); + $response->success = true; + $response->status = PaymentStatus::PAYMENT_STATUS_PENDING; + + return $response; + } + + /** + * Template-Method: Der gemeinsame Rechnungs-Rumpf wird hier gebaut, der variierende Schlusssatz + * kommt aus {@see invoiceClosingStatement()} des jeweiligen Moduls. + */ + public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse + { + $response = new CreateInvoiceResponse(); + // TODO: In einer Folge-Iteration den gemeinsamen Rumpf (Betrag, Leistung, Teilnehmerdaten) befüllen + // und an die Invoice-Domain anbinden. + $response->success = true; + $response->closingStatement = $this->invoiceClosingStatement($request); + + return $response; + } + + /** + * Der je Zahlungsart variierende Schlusssatz der Rechnung + * ("Bitte überweise bis ..." / "wird abgebucht ..." / "wurde per PayPal beglichen"). + */ + abstract protected function invoiceClosingStatement(CreateInvoiceRequest $request): string; +} diff --git a/app/EventPaymentModules/DTO/CreateInvoiceRequest.php b/app/EventPaymentModules/DTO/CreateInvoiceRequest.php new file mode 100644 index 0000000..b1fe852 --- /dev/null +++ b/app/EventPaymentModules/DTO/CreateInvoiceRequest.php @@ -0,0 +1,25 @@ + $configuration Aufgelöste (pro-Event-)Konfiguration des Moduls. + */ + public function __construct( + public readonly Event $event, + public readonly EventParticipant $participant, + public readonly Amount $amount, + public readonly array $configuration = [], + ) { + } +} diff --git a/app/EventPaymentModules/DTO/CreateInvoiceResponse.php b/app/EventPaymentModules/DTO/CreateInvoiceResponse.php new file mode 100644 index 0000000..ace7ac4 --- /dev/null +++ b/app/EventPaymentModules/DTO/CreateInvoiceResponse.php @@ -0,0 +1,21 @@ +> Gemeinsame Rechnungspositionen (Rumpf). */ + public array $lines = []; + + public ?string $closingStatement = null; + public ?string $message = null; +} diff --git a/app/EventPaymentModules/DTO/DoPaymentRequest.php b/app/EventPaymentModules/DTO/DoPaymentRequest.php new file mode 100644 index 0000000..e46e0a9 --- /dev/null +++ b/app/EventPaymentModules/DTO/DoPaymentRequest.php @@ -0,0 +1,25 @@ + $configuration Aufgelöste (pro-Event-)Konfiguration des Moduls. + */ + public function __construct( + public readonly Event $event, + public readonly EventParticipant $participant, + public readonly Amount $amount, + public readonly array $configuration = [], + ) { + } +} diff --git a/app/EventPaymentModules/DTO/DoPaymentResponse.php b/app/EventPaymentModules/DTO/DoPaymentResponse.php new file mode 100644 index 0000000..5dc2e48 --- /dev/null +++ b/app/EventPaymentModules/DTO/DoPaymentResponse.php @@ -0,0 +1,22 @@ + $configuration Aufgelöste (pro-Event-)Konfiguration des Moduls. + */ + public function __construct( + public readonly Event $event, + public readonly Amount $amount, + public readonly array $configuration = [], + ) { + } +} diff --git a/app/EventPaymentModules/DTO/RegistrationSummaryResponse.php b/app/EventPaymentModules/DTO/RegistrationSummaryResponse.php new file mode 100644 index 0000000..1eaa4b1 --- /dev/null +++ b/app/EventPaymentModules/DTO/RegistrationSummaryResponse.php @@ -0,0 +1,21 @@ + Zeilen des Zahlungs-Abschnitts. */ + public array $lines = []; + + /** Bestätigungstext für die Checkbox, z.B. "Ich bestätige, den Betrag von X zu überweisen.". */ + public ?string $confirmationLabel = null; +} diff --git a/app/EventPaymentModules/EventPaymentModule.php b/app/EventPaymentModules/EventPaymentModule.php new file mode 100644 index 0000000..c2b7390 --- /dev/null +++ b/app/EventPaymentModules/EventPaymentModule.php @@ -0,0 +1,57 @@ + + */ + public function getOptions(): array; + + /** + * Liefert den zahlungsspezifischen Teil der Anmelde-Zusammenfassung. + * (In dieser Iteration nur als Stub vorhanden.) + */ + public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse; + + /** + * Stößt die Zahlung an (bzw. beschreibt, was zu tun ist -- z.B. Redirect, Lastschrift, manuelle Überweisung). + * (In dieser Iteration nur als Stub vorhanden.) + */ + public function doPayment(DoPaymentRequest $request): DoPaymentResponse; + + /** + * Erzeugt die Rechnung/Zahlungsaufforderung. Der gemeinsame Rumpf kommt aus der Basisklasse, + * nur der Schlusssatz variiert je Modul. + * (In dieser Iteration nur als Stub vorhanden.) + */ + public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse; +} diff --git a/app/EventPaymentModules/EventPaymentModuleRegistry.php b/app/EventPaymentModules/EventPaymentModuleRegistry.php new file mode 100644 index 0000000..96698ba --- /dev/null +++ b/app/EventPaymentModules/EventPaymentModuleRegistry.php @@ -0,0 +1,54 @@ + Zahlungsmodul. Neue Zahlungsarten werden hier eingetragen. + * Bewusst statisch + manuelle Instanziierung (kein Container-Binding), passend zum Projektstil. + */ +class EventPaymentModuleRegistry +{ + /** + * Alle registrierten Modul-Klassen. Reihenfolge bestimmt u.a. die Seed-Reihenfolge. + * + * @var array> + */ + private const MODULES = [ + AccountTransferPaymentModule::class, + UndefinedPaymentModule::class, + ]; + + /** @var array|null Lazy gecachte Instanzen (slug => Modul). */ + private static ?array $instances = null; + + /** + * @return array + */ + public static function all(): array + { + if (self::$instances === null) { + self::$instances = []; + foreach (self::MODULES as $moduleClass) { + self::$instances[$moduleClass::slug()] = new $moduleClass(); + } + } + + return self::$instances; + } + + public static function forSlug(string $slug): ?EventPaymentModule + { + return self::all()[$slug] ?? null; + } + + /** + * @return array + */ + public static function slugs(): array + { + return array_keys(self::all()); + } +} diff --git a/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php b/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php new file mode 100644 index 0000000..8bebb25 --- /dev/null +++ b/app/EventPaymentModules/Modules/AccountTransferPaymentModule.php @@ -0,0 +1,38 @@ + 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true], + ['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true], + ['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false], + ]; + } + + protected function invoiceClosingStatement(CreateInvoiceRequest $request): string + { + // TODO: In einer Folge-Iteration ausformulieren (z.B. "Bitte überweise bis zum ... auf IBAN ..."). + return ''; + } +} diff --git a/app/EventPaymentModules/Modules/UndefinedPaymentModule.php b/app/EventPaymentModules/Modules/UndefinedPaymentModule.php new file mode 100644 index 0000000..16f543d --- /dev/null +++ b/app/EventPaymentModules/Modules/UndefinedPaymentModule.php @@ -0,0 +1,33 @@ +installParticipationTypes(); $this->installEfzStatus(); $this->installPaymentMethods(); + $this->installPaymentStatus(); + } + + private function installPaymentStatus() { + PaymentStatus::create(['slug' => PaymentStatus::PAYMENT_STATUS_PENDING]); + PaymentStatus::create(['slug' => PaymentStatus::PAYMENT_STATUS_SCHEDULED]); + PaymentStatus::create(['slug' => PaymentStatus::PAYMENT_STATUS_PAID]); + PaymentStatus::create(['slug' => PaymentStatus::PAYMENT_STATUS_FAILED]); } private function installPaymentMethods() { - PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); - PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); + foreach (EventPaymentModuleRegistry::slugs() as $slug) { + PaymentMethod::create(['slug' => $slug]); + } } private function installEfzStatus() { diff --git a/app/Models/AvailablePaymentMethod.php b/app/Models/AvailablePaymentMethod.php index a8b8c27..7d03ff0 100644 --- a/app/Models/AvailablePaymentMethod.php +++ b/app/Models/AvailablePaymentMethod.php @@ -39,7 +39,7 @@ class AvailablePaymentMethod extends InstancedModel } /** - * Sind alle Pflicht-Optionen dieses Moduls (siehe PaymentMethod::OPTIONS) befüllt? + * Sind alle Pflicht-Optionen dieses Moduls (siehe Options-Schema des Zahlungsmoduls) befüllt? */ public function isComplete(): bool { diff --git a/app/Models/PaymentMethod.php b/app/Models/PaymentMethod.php index 7966ebc..addc670 100644 --- a/app/Models/PaymentMethod.php +++ b/app/Models/PaymentMethod.php @@ -2,12 +2,17 @@ namespace App\Models; +use App\EventPaymentModules\EventPaymentModuleRegistry; use App\Scopes\CommonModel; use Illuminate\Database\Eloquent\Concerns\HasUuids; /** * @property string $id * @property string $slug + * + * Hinweis: Das Verhalten je Zahlungsart (Options-Schema, Defaults, Zahlung, Rechnung) liegt in den + * Zahlungsmodulen unter {@see \App\EventPaymentModules}. Dieses Model bleibt eine dünne Fassade, damit + * bestehende Aufrufstellen (PaymentMethod::optionsFor/... ) unverändert weiterlaufen. */ class PaymentMethod extends CommonModel { @@ -19,33 +24,25 @@ class PaymentMethod extends CommonModel public const string PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION'; public const string PAYMENT_NOT_DEFINED = 'PAYMENT_NOT_DEFINED'; - public const array DEFAULTS = [ - self::PAYMENT_ACCOUNT_TRANSACTION => ['name' => 'Überweisung auf Veranstaltungskonto', 'description' => null], - self::PAYMENT_NOT_DEFINED => ['name' => 'Sonstiges (Barzahlung, Zahlung vor Ort)', 'description' => null], - ]; - - /** - * Options-Schema je Zahlungsmodul. Definiert im Code (nicht in der DB), damit es - * nicht mit dem Modul-Code auseinanderdriftet. In der DB stehen nur die Werte - * (configuration-JSON auf available_payment_methods bzw. event_payment_methods). - * - * Jede Option: ['name' => string, 'label' => string, 'type' => string, 'required' => bool] - * - * @var array> - */ - public const array OPTIONS = [ - self::PAYMENT_ACCOUNT_TRANSACTION => [ - ['name' => 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true], - ['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true], - ['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false], - ], - self::PAYMENT_NOT_DEFINED => [], - ]; - protected $fillable = [ 'slug', ]; + /** + * Standard-Werte (name/description) je Slug -- aus den Zahlungsmodulen aufgebaut. + * + * @return array + */ + public static function defaults(): array + { + $defaults = []; + foreach (EventPaymentModuleRegistry::all() as $slug => $module) { + $defaults[$slug] = ['name' => $module->defaultName(), 'description' => $module->defaultDescription()]; + } + + return $defaults; + } + /** * Options-Schema für einen Slug. * @@ -53,7 +50,7 @@ class PaymentMethod extends CommonModel */ public static function optionsFor(string $slug): array { - return self::OPTIONS[$slug] ?? []; + return EventPaymentModuleRegistry::forSlug($slug)?->getOptions() ?? []; } /** @@ -63,45 +60,28 @@ class PaymentMethod extends CommonModel */ public static function requiredOptionKeys(string $slug): array { - return array_values(array_map( - static fn (array $option) => $option['name'], - array_filter(self::optionsFor($slug), static fn (array $option) => $option['required'] ?? false) - )); + return EventPaymentModuleRegistry::forSlug($slug)?->requiredOptionKeys() ?? []; } /** - * Reduziert eine Konfiguration auf die im Code definierten Options-Keys des Slugs. - * Unbekannte Keys werden verworfen -- das Schema (self::OPTIONS) ist die Autorität. + * Reduziert eine Konfiguration auf die im Schema definierten Options-Keys des Slugs. * * @param array $config * @return array */ public static function sanitizeConfiguration(string $slug, array $config): array { - $result = []; - foreach (self::optionsFor($slug) as $option) { - if (array_key_exists($option['name'], $config)) { - $result[$option['name']] = $config[$option['name']]; - } - } - - return $result; + return EventPaymentModuleRegistry::forSlug($slug)?->sanitizeConfiguration($config) ?? []; } /** * Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind. + * Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder. * * @param array $config */ public static function isConfigurationComplete(string $slug, array $config): bool { - foreach (self::requiredOptionKeys($slug) as $key) { - $value = $config[$key] ?? null; - if ($value === null || $value === '' || $value === []) { - return false; - } - } - - return true; + return EventPaymentModuleRegistry::forSlug($slug)?->isConfigurationComplete($config) ?? true; } } diff --git a/database/migrations/2026_07_29_140040_create_payment_status.php b/database/migrations/2026_07_29_140040_create_payment_status.php new file mode 100644 index 0000000..b7bb218 --- /dev/null +++ b/database/migrations/2026_07_29_140040_create_payment_status.php @@ -0,0 +1,19 @@ +string('slug')->primary(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_status'); + } +}; diff --git a/tests/Unit/EventPaymentModuleRegistryTest.php b/tests/Unit/EventPaymentModuleRegistryTest.php new file mode 100644 index 0000000..80449aa --- /dev/null +++ b/tests/Unit/EventPaymentModuleRegistryTest.php @@ -0,0 +1,59 @@ +assertInstanceOf( + AccountTransferPaymentModule::class, + EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION) + ); + $this->assertInstanceOf( + UndefinedPaymentModule::class, + EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_NOT_DEFINED) + ); + } + + public function test_for_slug_returns_null_for_unknown(): void + { + $this->assertNull(EventPaymentModuleRegistry::forSlug('DOES_NOT_EXIST')); + } + + public function test_slugs_lists_all_registered_modules(): void + { + $slugs = EventPaymentModuleRegistry::slugs(); + + $this->assertContains(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $slugs); + $this->assertContains(PaymentMethod::PAYMENT_NOT_DEFINED, $slugs); + } + + public function test_module_option_helpers(): void + { + $module = new AccountTransferPaymentModule(); + + $this->assertSame(['account_owner', 'iban'], $module->requiredOptionKeys()); + // Rückgabe folgt der Schema-Reihenfolge, unbekannte Keys werden verworfen. + $this->assertSame( + ['account_owner' => 'Kasse', 'iban' => 'DE123'], + $module->sanitizeConfiguration(['iban' => 'DE123', 'account_owner' => 'Kasse', 'evil' => 'x']) + ); + $this->assertFalse($module->isConfigurationComplete(['iban' => 'DE123'])); + $this->assertTrue($module->isConfigurationComplete(['iban' => 'DE123', 'account_owner' => 'Kasse'])); + } + + public function test_defaults_are_built_from_modules(): void + { + $defaults = PaymentMethod::defaults(); + + $this->assertSame('Überweisung auf Veranstaltungskonto', $defaults[PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]['name']); + $this->assertArrayHasKey(PaymentMethod::PAYMENT_NOT_DEFINED, $defaults); + } +}