Feat: Creating PaymentMethods, Setting Fees, Availabilities

This commit is contained in:
2026-08-31 13:54:20 +03:00
parent d873cb4931
commit 2db1e1331f
12 changed files with 359 additions and 1 deletions
+9
View File
@@ -1,5 +1,6 @@
<?php <?php
use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee; use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
return [ return [
@@ -12,10 +13,18 @@ return [
| boboko-core gets cash-on-delivery out of the box, without publishing | boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config. | Lunar's own config.
| |
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/ */
'types' => [ 'types' => [
'cash-on-delivery' => [ 'cash-on-delivery' => [
'driver' => 'offline', 'driver' => 'offline',
'payment_driver' => CashOnDeliveryPaymentDriver::class,
'authorized' => 'awaiting-payment', 'authorized' => 'awaiting-payment',
'fee' => 0, 'fee' => 0,
], ],
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->string('type')->unique();
$table->boolean('enabled')->default(true);
$table->json('data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectPaymentMethod() — carries the plain
* type key (e.g. 'cash-on-delivery', 'stripe'), same convention as
* CartService's events (a plain reference the listener resolves further
* itself, rather than an already-resolved object) since a payment type key
* has nothing further to eagerly resolve the way a ShippingOption does.
*/
class PaymentMethodSelected
{
public function __construct(
public readonly Cart $cart,
public readonly string $type,
) {}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectPaymentMethod()/confirmPayment() when
* $type doesn't resolve to a registered Modules\Core\Checkout\Contracts\
* PaymentDriver (config('payment.drivers')) — same reasoning as
* InvalidShippingOptionException: nothing here has a matching Lunar
* exception type to reuse, so this is the boboko-owned signal instead of a
* silent no-op or an opaque container-resolution error.
*/
class UnknownPaymentTypeException extends RuntimeException
{
public function __construct(public readonly string $type)
{
parent::__construct("The payment type \"{$type}\" is not registered.");
}
}
+62
View File
@@ -12,11 +12,14 @@ use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -123,4 +126,63 @@ class CheckoutService
return $order; return $order;
} }
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before the shopper reaches placeOrder()/confirmPayment().
* Does not itself call a PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type has no registered
* PaymentDriver (config('lunar.payments.types.<type>.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<string, mixed> $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);
}
} }
+29
View File
@@ -21,6 +21,7 @@ use Lunar\Models\TaxZone;
use Modules\Core\Localization\Models\LanguageLine; use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels; use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService; use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate * 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->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations); $this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
$this->components->info('Publishing Filament assets'); $this->components->info('Publishing Filament assets');
$this->call('filament:assets'); $this->call('filament:assets');
@@ -278,4 +282,29 @@ class InstallLunarCommand extends Command
$translations->create('storefront', $key, $text); $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' => [],
]);
}
}
} }
+2
View File
@@ -25,6 +25,7 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; 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\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\OrderViewExtension;
@@ -49,6 +50,7 @@ class CorePlugin implements Plugin
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
CartResource::class, CartResource::class,
PaymentMethodResource::class,
]) ])
->plugin(ShippingPlugin::make()) ->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]); ->pages([ManagePickupManifests::class]);
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
/**
* Cash-on-delivery has no gateway to confirm against — the shopper pays the
* courier on delivery, not at checkout — so confirm() has nothing to wait
* on and places the order immediately, same as Lunar's own OfflinePayment
* would, but through CheckoutService::placeOrder() so it goes through the
* same fingerprint check every other driver does. $data is unused: nothing
* about this confirmation depends on gateway-specific payload.
*
* Sets the order status to config('lunar.payments.types.cash-on-delivery.authorized')
* afterward — placeOrder() itself leaves the order at Lunar's configured
* draft_status, same as every driver is responsible for moving it on from.
*/
class CashOnDeliveryPaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $fingerprint, array $data): Order
{
$order = $this->checkout->placeOrder($fingerprint);
$order->update([
'status' => config('lunar.payments.types.cash-on-delivery.authorized', $order->status),
]);
return $order->refresh();
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* One row per payment type key (config('lunar.payments.types')), seeded by
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
* toggles inline; `data.fee` (currently the only type-specific setting, for
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
* via a modal action rather than a dedicated form field, since not every
* type has the same data keys.
*/
class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
protected static ?string $pluralModelLabel = 'Payment Methods';
public static function table(Table $table): Table
{
return $table
->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;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
class ListPaymentMethods extends ListRecords
{
protected static string $resource = PaymentMethodResource::class;
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-editable settings for one payment type key (matching a key in
* config('lunar.payments.types')) — enabled/disabled, and whatever type-
* specific data it needs (starts with 'fee' for cash-on-delivery's flat
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data'
* column holding keyed settings, rather than a fixed column per setting or
* a separate conditions table — new settings are a code change (a new key
* read from data), not a migration.
*
* Seeded once per type by InstallLunarCommand (skip-if-exists, same
* idempotent convention as seedStorefrontLabels()) — never auto-created on
* read, so a read path stays a pure read.
*/
class PaymentMethod extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'data' => AsArrayObject::class,
];
}
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Pipelines\Cart;
use Closure; use Closure;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract; use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee final class ApplyCashOnDeliveryFee
{ {
@@ -16,7 +17,7 @@ final class ApplyCashOnDeliveryFee
public function handle(CartContract $cart, Closure $next): mixed public function handle(CartContract $cart, Closure $next): mixed
{ {
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') { 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 = new Price(
($cart->shippingTotal?->value ?? 0) + $fee, ($cart->shippingTotal?->value ?? 0) + $fee,