From 2db1e1331fd57155317c4af480ad7d26c68eb39d Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 31 Aug 2026 13:54:20 +0300 Subject: [PATCH] Feat: Creating PaymentMethods, Setting Fees, Availabilities --- config/payment.php | 9 ++ ...31_000001_create_payment_methods_table.php | 24 ++++ src/Checkout/Events/PaymentMethodSelected.php | 20 ++++ .../UnknownPaymentTypeException.php | 21 ++++ src/Checkout/Services/CheckoutService.php | 62 +++++++++++ src/Command/InstallLunarCommand.php | 29 +++++ src/CorePlugin.php | 2 + .../Drivers/CashOnDeliveryPaymentDriver.php | 46 ++++++++ .../Resources/PaymentMethodResource.php | 104 ++++++++++++++++++ .../Pages/ListPaymentMethods.php | 11 ++ src/Payment/Models/PaymentMethod.php | 29 +++++ .../Pipelines/Cart/ApplyCashOnDeliveryFee.php | 3 +- 12 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_31_000001_create_payment_methods_table.php create mode 100644 src/Checkout/Events/PaymentMethodSelected.php create mode 100644 src/Checkout/Exceptions/UnknownPaymentTypeException.php create mode 100644 src/Payment/Drivers/CashOnDeliveryPaymentDriver.php create mode 100644 src/Payment/Filament/Resources/PaymentMethodResource.php create mode 100644 src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php create mode 100644 src/Payment/Models/PaymentMethod.php diff --git a/config/payment.php b/config/payment.php index c82088f..02ee66d 100644 --- a/config/payment.php +++ b/config/payment.php @@ -1,5 +1,6 @@ [ 'cash-on-delivery' => [ 'driver' => 'offline', + 'payment_driver' => CashOnDeliveryPaymentDriver::class, 'authorized' => 'awaiting-payment', 'fee' => 0, ], diff --git a/database/migrations/2026_08_31_000001_create_payment_methods_table.php b/database/migrations/2026_08_31_000001_create_payment_methods_table.php new file mode 100644 index 0000000..4ac80b7 --- /dev/null +++ b/database/migrations/2026_08_31_000001_create_payment_methods_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('type')->unique(); + $table->boolean('enabled')->default(true); + $table->json('data')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_methods'); + } +}; diff --git a/src/Checkout/Events/PaymentMethodSelected.php b/src/Checkout/Events/PaymentMethodSelected.php new file mode 100644 index 0000000..4e29544 --- /dev/null +++ b/src/Checkout/Events/PaymentMethodSelected.php @@ -0,0 +1,20 @@ +.payment_driver')) + */ + public function selectPaymentMethod(string $type): Cart + { + $this->paymentDriverFor($type); + + $cart = $this->cart->currentOrCreate(); + $cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type]; + $cart->save(); + + Event::dispatch(new PaymentMethodSelected($cart, $type)); + + return $cart; + } + + /** + * Resolves $type's registered PaymentDriver and calls confirm() — + * the driver decides whether/when the order actually gets placed (see + * Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data + * carries whatever that driver needs (Stripe's payment_intent id, a + * future redirect-based provider's callback payload). + * + * @param array $data + * + * @throws UnknownPaymentTypeException if $type has no registered driver + * @throws \Lunar\Exceptions\FingerprintMismatchException + * @throws \Lunar\Exceptions\Carts\CartException + */ + public function confirmPayment(string $type, string $fingerprint, array $data = []): Order + { + $driver = $this->paymentDriverFor($type); + + return $driver->confirm($this->cart->currentOrCreate(), $fingerprint, $data); + } + + /** + * @throws UnknownPaymentTypeException + */ + private function paymentDriverFor(string $type): PaymentDriver + { + $driverClass = config("lunar.payments.types.{$type}.payment_driver"); + + if (! $driverClass) { + throw new UnknownPaymentTypeException($type); + } + + return app($driverClass); + } } diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index e55b9c9..324dc68 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -21,6 +21,7 @@ use Lunar\Models\TaxZone; use Modules\Core\Localization\Models\LanguageLine; use Modules\Core\Localization\Services\StorefrontLabels; use Modules\Core\Localization\Services\TranslationService; +use Modules\Core\Payment\Models\PaymentMethod; /** * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate @@ -247,6 +248,9 @@ class InstallLunarCommand extends Command $this->components->info('Seeding storefront label translations'); $this->seedStorefrontLabels($translations); + $this->components->info('Seeding payment method settings'); + $this->seedPaymentMethods(); + $this->components->info('Publishing Filament assets'); $this->call('filament:assets'); @@ -278,4 +282,29 @@ class InstallLunarCommand extends Command $translations->create('storefront', $key, $text); } } + + /** + * Per-type skip-if-exists, same idempotent convention as + * seedStorefrontLabels() — a type already present (including one an + * admin has since edited via the Filament Payment Methods resource) is + * left untouched. Safe to re-run after a new payment type is added to + * config('lunar.payments.types') (e.g. installing a Stripe/Nexi + * package), which is the whole reason this isn't a one-time-only seed. + */ + private function seedPaymentMethods(): void + { + $existingTypes = PaymentMethod::pluck('type'); + + foreach (array_keys(config('lunar.payments.types', [])) as $type) { + if ($existingTypes->contains($type)) { + continue; + } + + PaymentMethod::create([ + 'type' => $type, + 'enabled' => true, + 'data' => [], + ]); + } + } } diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 5d19cd4..92e9ef0 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -25,6 +25,7 @@ use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; +use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; use Modules\Core\Shipping\Extensions\OrderViewExtension; @@ -49,6 +50,7 @@ class CorePlugin implements Plugin ->resources([ LanguageLineResource::class, CartResource::class, + PaymentMethodResource::class, ]) ->plugin(ShippingPlugin::make()) ->pages([ManagePickupManifests::class]); diff --git a/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php new file mode 100644 index 0000000..79c927c --- /dev/null +++ b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php @@ -0,0 +1,46 @@ +checkout->placeOrder($fingerprint); + + $order->update([ + 'status' => config('lunar.payments.types.cash-on-delivery.authorized', $order->status), + ]); + + return $order->refresh(); + } +} diff --git a/src/Payment/Filament/Resources/PaymentMethodResource.php b/src/Payment/Filament/Resources/PaymentMethodResource.php new file mode 100644 index 0000000..d495326 --- /dev/null +++ b/src/Payment/Filament/Resources/PaymentMethodResource.php @@ -0,0 +1,104 @@ +columns([ + TextColumn::make('type') + ->label('Type'), + ToggleColumn::make('enabled') + ->label('Enabled'), + TextColumn::make('data.fee') + ->label('Fee') + ->formatStateUsing(fn (?int $state) => $state + ? number_format($state / 100, 2) + : '—'), + TextColumn::make('updated_at') + ->label('Last updated') + ->dateTime(), + ]) + ->recordActions([ + static::editFeeAction(), + ]) + ->defaultSort('type'); + } + + /** + * $data['fee'] is stored as an integer minor unit (cents), matching + * Lunar's own Price convention everywhere else in this codebase — the + * form collects/displays a decimal and converts at the boundary. + */ + private static function editFeeAction(): Action + { + return Action::make('edit_fee') + ->label('Edit fee') + ->icon('heroicon-o-pencil') + ->schema([ + TextInput::make('fee') + ->label('Fee') + ->numeric() + ->minValue(0) + ->step(0.01) + ->helperText('Flat surcharge added when this payment method is selected.'), + ]) + ->fillForm(fn (PaymentMethod $record) => [ + 'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null, + ]) + ->action(function (PaymentMethod $record, array $data) { + $record->update([ + 'data' => [ + ...$record->data->toArray(), + 'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null, + ], + ]); + }); + } + + public static function getPages(): array + { + return [ + 'index' => ListPaymentMethods::route('/'), + ]; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canDelete($record = null): bool + { + return false; + } +} diff --git a/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php b/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php new file mode 100644 index 0000000..90edd3b --- /dev/null +++ b/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php @@ -0,0 +1,11 @@ + 'boolean', + 'data' => AsArrayObject::class, + ]; +} diff --git a/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php b/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php index 573b161..4ad6f67 100644 --- a/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php +++ b/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php @@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Pipelines\Cart; use Closure; use Lunar\DataTypes\Price; use Lunar\Models\Contracts\Cart as CartContract; +use Modules\Core\Payment\Models\PaymentMethod; final class ApplyCashOnDeliveryFee { @@ -16,7 +17,7 @@ final class ApplyCashOnDeliveryFee public function handle(CartContract $cart, Closure $next): mixed { if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') { - $fee = (int) config('lunar.payments.types.cash-on-delivery.fee', 0); + $fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0); $cart->shippingTotal = new Price( ($cart->shippingTotal?->value ?? 0) + $fee,