diff --git a/config/payment.php b/config/payment.php index 70b07bc..7fa523e 100644 --- a/config/payment.php +++ b/config/payment.php @@ -1,37 +1,8 @@ [ - 'cash-on-delivery' => [ - 'driver' => 'offline', - 'payment_driver' => OfflinePaymentDriver::class, - 'capture_mode' => 'pay', - 'captured_status' => 'payment-offline', - 'fee' => 0, - ], - ], - /* |-------------------------------------------------------------------------- | Lunar cart pipeline additions @@ -41,6 +12,15 @@ return [ | the cash-on-delivery fee is added to the shipping total before the | final Calculate step sums everything up. | + | This is the one thing left in this file — everything about WHICH + | payment methods exist (driver mapping, capture_mode, statuses) moved + | onto Modules\Core\Payment\Models\PaymentMethod's own row (see + | docs/payments.md): that's a per-instance, merchant decision, not a + | store-wide-singular setting, so it never belonged in config at all. + | This pipeline registration IS genuinely cross-cutting — every store + | using this driver gets the same cart-pipeline wiring, regardless of + | how many payment methods it configures. + | */ 'cart_pipeline' => [ ApplyCashOnDeliveryFee::class, diff --git a/database/migrations/2026_09_05_000001_add_driver_columns_to_payment_methods.php b/database/migrations/2026_09_05_000001_add_driver_columns_to_payment_methods.php new file mode 100644 index 0000000..0ea1f28 --- /dev/null +++ b/database/migrations/2026_09_05_000001_add_driver_columns_to_payment_methods.php @@ -0,0 +1,52 @@ +string('name')->nullable()->after('type'); + $table->string('driver')->nullable()->after('name'); + $table->string('capture_mode')->nullable()->after('driver'); + $table->string('captured_status')->nullable()->after('capture_mode'); + $table->string('authorized_status')->nullable()->after('captured_status'); + $table->unsignedInteger('position')->default(0)->after('authorized_status'); + $table->timestamp('driver_missing_at')->nullable()->after('position'); + }); + } + + public function down(): void + { + Schema::table('payment_methods', function (Blueprint $table) { + $table->dropColumn([ + 'name', 'driver', 'capture_mode', 'captured_status', + 'authorized_status', 'position', 'driver_missing_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_08_000001_add_refunded_status_to_payment_methods.php b/database/migrations/2026_09_08_000001_add_refunded_status_to_payment_methods.php new file mode 100644 index 0000000..00b973e --- /dev/null +++ b/database/migrations/2026_09_08_000001_add_refunded_status_to_payment_methods.php @@ -0,0 +1,38 @@ +string('refunded_status')->nullable()->after('authorized_status'); + }); + } + + public function down(): void + { + Schema::table('payment_methods', function (Blueprint $table) { + $table->dropColumn('refunded_status'); + }); + } +}; diff --git a/resources/views/order/infolists/transaction.blade.php b/resources/views/order/infolists/transaction.blade.php new file mode 100644 index 0000000..0131675 --- /dev/null +++ b/resources/views/order/infolists/transaction.blade.php @@ -0,0 +1,127 @@ +@php + $transaction = $getRecord(); + $notes = $transaction->notes ?: ($transaction->meta['notes'] ?? null); +@endphp + +@once + @php + $renderPaymentIcons(); + @endphp +@endonce +
type){ + 'refund' => 'border-orange-300', + 'intent' => 'border-sky-300', + 'capture' => 'border-green-300', + default => 'border-gray-300', + }, + '!border-red-500 bg-red-50' => !$transaction->success, + 'bg-gray-50' => $transaction->success, + ]) +> +
+
+ {{ $transaction->driver }} // + {{ $transaction->reference }} +
+ +
+
+
+ + {{ $transaction->status }} + +
+ +
+ + + +
+ + @if($transaction->last_four) +

+ + ∗∗∗∗ ∗∗∗∗ ∗∗∗∗ + + + + {{ (string) $transaction->last_four }} + +

+ @endif +
+ + !$transaction->success, + match($transaction->type){ + 'refund' => "text-orange-500", + default => "text-gray-900 dark:text-gray-100", + }, + ]) + > + @if($transaction->type == 'refund')-@endif{{ $transaction->amount->formatted }} + +
+ +
+
+
+ +
+ {{ $transaction->created_at->format('jS F Y h:ia') }} +
+ +
+ @foreach($transaction->paymentChecks() as $check) + + {{ $check->label }}: {{ $check->message }} + + @endforeach +
+
+ + @if($notes) +
+
+ +
+ +

{{ $notes }}

+
+ @endif +
+ +
!$transaction->success, + match($transaction->type){ + 'refund' => "bg-orange-50 dark:bg-orange-400/10 border-orange-300 text-orange-600 dark:text-orange-400", + 'intent' => "bg-sky-50 dark:bg-sky-400/10 border-sky-300 text-sky-600 dark:text-sky-400", + 'capture' => "bg-green-50 dark:bg-green-400/10 border-green-300 text-green-600 dark:text-green-400", + default => "bg-gray-50 dark:bg-gray-400/10 border-gray-300 text-gray-600 dark:text-gray-400", + }, + ]) + > + @if(!$transaction->success) + {{ __('lunarpanel::order.transactions.failed') }} + @else + {{ __('lunarpanel::order.transactions.'.$transaction->type) }} + @endif +
+
diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index d02a342..bee0604 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -19,7 +19,8 @@ use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Models\PaymentMethod; -use Modules\Core\Payment\Services\PaymentDriverResolver; +use Modules\Core\Payment\Services\PaymentDriverRegistry; +use Modules\Core\Payment\Services\PaymentMethodCache; /** * Storefront-facing checkout operations, mirroring @@ -42,7 +43,8 @@ class CheckoutService { public function __construct( private readonly CartService $cart, - private readonly PaymentDriverResolver $paymentDrivers, + private readonly PaymentDriverRegistry $paymentDrivers, + private readonly PaymentMethodCache $paymentMethods, ) {} public function setShippingAddress(array|Addressable $address): Cart @@ -100,25 +102,28 @@ class CheckoutService } /** - * Every payment type currently offered to the storefront — every key - * in config('lunar.payments.types') that is BOTH administratively - * enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND - * whose registered driver reports itself usable right now - * (Configurable::isConfigured() — e.g. Stripe with no API key set is - * never offered, regardless of the enabled toggle). A type with no - * PaymentMethod row at all (never seeded) is treated as not offered, - * same as disabled — nothing here creates one; see - * InstallLunarCommand::seedPaymentMethods(). + * Every payment method currently offered to the storefront, ordered by + * Modules\Core\Payment\Models\PaymentMethod::position — a row is + * offered only when ALL three checks pass, each meaning something + * different to an admin diagnosing why a method isn't showing up (see + * docs/payments.md): + * 1. `enabled` — an admin turned it on. + * 2. its `driver` still resolves via PaymentDriverRegistry — the + * driver class hasn't been removed (see the `payment:sync-drivers` + * command, which sets `driver_missing_at` when this fails; a row + * with that set is excluded here regardless of `enabled`, so a + * vanished driver can never silently look "available"). + * 3. the resolved driver reports Configurable::isConfigured() — its + * own runtime requirements (e.g. an API key) are met. * - * @return array + * @return Collection */ - public function getPaymentMethods(): array + public function getPaymentMethods(): Collection { - return PaymentMethod::where('enabled', true) - ->pluck('type') - ->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false) - ->values() - ->all(); + return $this->paymentMethods->all() + ->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) + ->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false) + ->values(); } /** @@ -142,12 +147,11 @@ class CheckoutService * as selecting a shipping option happens before placing the order. * * @throws UnknownPaymentTypeException if $type isn't currently offered - * — see getPaymentMethods() for what that means (registered, - * administratively enabled, and its driver reports itself usable) + * — see getPaymentMethods() for what that means */ public function selectPaymentMethod(string $type): Cart { - if (! in_array($type, $this->getPaymentMethods(), true)) { + if (! $this->getPaymentMethods()->contains('type', $type)) { throw new UnknownPaymentTypeException($type); } @@ -170,11 +174,9 @@ class CheckoutService * Order exists (Cart::createOrder() — confirmed idempotent against a * cart's own pre-existing, not-yet-placed-at draft; see * vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then - * resolves the payment type selected by selectPaymentMethod() and - * calls pay() or authorize() on its driver, per that type's - * config('lunar.payments.types.{type}.capture_mode') — boboko-core's - * own types (config/payment.php) are merged into that same Lunar - * config key by PaymentServiceProvider::boot(). + * resolves the payment method selected by selectPaymentMethod() and + * calls pay() or authorize() on its driver, per that method's own + * `capture_mode` column. * * Returns the driver's own PaymentResult UNCHANGED — this method does * not wait for or resolve anything past what pay()/authorize() itself @@ -183,14 +185,6 @@ class CheckoutService * outcome, not an error — the caller (a storefront controller) is * responsible for whatever the gateway needs next. * - * KNOWN GAP, explicitly out of scope for now: PaymentResult alone does - * not carry gateway-specific continuation data (e.g. Stripe's - * PaymentIntent client_secret for a Pending result needing frontend - * confirmation) — that concept existed on the deleted PaymentInitiation - * DTO and was intentionally removed from Payment's abstraction layer. - * Nothing here re-introduces it; only OfflinePaymentDriver's - * always-Immediate-Succeeded path is fully wired end-to-end today. - * * The draft order's own $order->total (not the Cart's) is what gets * passed as $amount — Order::$total is Lunar's own Price-cast * attribute, already resolving the correct Currency via the order's @@ -210,8 +204,8 @@ class CheckoutService * * @throws UnknownPaymentTypeException if the cart's selected * payment_method (from selectPaymentMethod()) is no longer offered - * — re-checked here, not just at selection time, since a type could - * be disabled in between + * — re-checked here, not just at selection time, since a method + * could be disabled (or its driver removed) in between * @throws FingerprintMismatchException * @throws CartException */ @@ -221,19 +215,18 @@ class CheckoutService $cart->checkFingerprint($fingerprint); $type = $cart->meta['payment_method'] ?? null; + $method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null; - if ($type === null || ! in_array($type, $this->getPaymentMethods(), true)) { + if ($method === null) { throw new UnknownPaymentTypeException((string) $type); } $order = $cart->createOrder(); - $driver = $this->paymentDrivers->resolve($type); - $captureMode = config("lunar.payments.types.{$type}.capture_mode", 'pay'); - + $driver = $this->paymentDrivers->resolve($method->driver); $context = ['cart_id' => $cart->id, 'order_id' => $order->id]; - return $captureMode === 'authorize' + return $method->capture_mode === 'authorize' ? $driver->authorize($type, $order->total, $data, $context) : $driver->pay($type, $order->total, $data, $context); } diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index 6b92788..0475e72 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -284,35 +284,39 @@ class InstallLunarCommand extends Command } /** - * 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. + * A single, deliberately opinionated starter row on fresh install — + * `PaymentMethod` is now fully admin-creatable/deletable (see + * docs/payments.md), so this is no longer "seed every config-defined + * type," it's "give a fresh store one reasonable payment method to + * start from instead of zero." Every value here is a plain literal in + * THIS command, not sourced from config or PaymentDriverRegistry — a + * driver has no business carrying opinions about what its captured + * order status should be called; that's a merchant decision. * - * Seeded disabled — a newly-seeded row (whether from this store's - * initial install, or a payment provider package installed later) - * shouldn't go live for shoppers before staff have actually reviewed - * it (real credentials configured, a fee set, etc.) and turned it on - * via the Payment Methods resource. See CheckoutService:: - * getPaymentMethods(), which only offers a type once both 'enabled' - * here and its driver's own isConfigured() check pass. + * Skip-if-exists on `type`, same idempotent convention as + * seedStorefrontLabels() — an admin who has since edited or deleted + * this row (via the Filament Payment Methods resource) is left alone; + * re-running lunar:install never recreates a deleted starter row. + * + * Seeded disabled — shouldn't go live for shoppers before staff have + * actually reviewed it and turned it on via the Payment Methods + * resource. See CheckoutService::getPaymentMethods(). */ 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' => false, - 'data' => [], - ]); + if (PaymentMethod::where('type', 'cash-on-delivery')->exists()) { + return; } + + PaymentMethod::create([ + 'type' => 'cash-on-delivery', + 'name' => 'Cash on Delivery', + 'driver' => 'offline', + 'capture_mode' => 'pay', + 'captured_status' => 'payment-offline', + 'position' => 0, + 'enabled' => false, + 'data' => [], + ]); } } diff --git a/src/Command/SyncPaymentDriversCommand.php b/src/Command/SyncPaymentDriversCommand.php new file mode 100644 index 0000000..c8bd59e --- /dev/null +++ b/src/Command/SyncPaymentDriversCommand.php @@ -0,0 +1,52 @@ +each(function (PaymentMethod $method) use ($registry, &$missing, &$restored) { + $resolves = $method->driver !== null && $registry->resolve($method->driver) !== null; + + if (! $resolves && $method->driver_missing_at === null) { + $method->update(['driver_missing_at' => now()]); + $missing++; + } elseif ($resolves && $method->driver_missing_at !== null) { + $method->update(['driver_missing_at' => null]); + $restored++; + } + }); + + $this->components->info("Payment driver sync complete: {$missing} newly flagged, {$restored} restored."); + + return self::SUCCESS; + } +} diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 92e9ef0..583e79d 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -3,6 +3,7 @@ namespace Modules\Core; use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder; +use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable; use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -25,6 +26,9 @@ 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\Order\Filament\Extensions\OrderItemsTableExtension; +use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension; +use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -62,7 +66,8 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - ManageOrder::class => OrderViewExtension::class, + ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class], + OrderItemsTable::class => OrderItemsTableExtension::class, ]); Product::macro('reviews', function (): HasMany { diff --git a/src/Order/Filament/Extensions/OrderItemsTableExtension.php b/src/Order/Filament/Extensions/OrderItemsTableExtension.php new file mode 100644 index 0000000..8fda303 --- /dev/null +++ b/src/Order/Filament/Extensions/OrderItemsTableExtension.php @@ -0,0 +1,50 @@ +toolbarActions( + array_map( + fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund' + ? $this->fixFailureNotification($action) + : $action, + $table->getToolbarActions(), + ), + ); + } + + private function fixFailureNotification(BulkAction $action): BulkAction + { + $originalAction = $action->getActionFunction(); + + if ($originalAction === null) { + return $action; + } + + return $action->action(function (array $arguments) use ($action, $originalAction) { + try { + return $action->evaluate($originalAction, $arguments); + } catch (Halt $exception) { + $action->sendFailureNotification(); + + throw $exception; + } + }); + } +} diff --git a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php b/src/Order/Filament/Extensions/OrderRefundActionsExtension.php new file mode 100644 index 0000000..39639d2 --- /dev/null +++ b/src/Order/Filament/Extensions/OrderRefundActionsExtension.php @@ -0,0 +1,199 @@ +failureNotification(...); $action->failure(); $action->halt(); + * but Filament\Actions\Concerns\InteractsWithActions::callMountedAction() + * only ever calls sendFailureNotification() from a match($action->getStatus()) + * block that runs AFTER the action's call() returns normally — halt() throws + * Filament\Support\Exceptions\Halt, which is caught in an earlier catch block + * that rolls back the DB transaction and returns null, never reaching that + * match block. So the notification set via failureNotification() is built + * but never sent: the admin sees the modal just close/reset with no + * indication anything happened. This was always broken in Lunar; it was + * invisible before because nothing in this codebase's Transaction::driver() + * could return a real, honest failure — see Payment\Support\ + * TransactionDriverAdapter's own docblock for that history. + * + * Fix, for capture: wrap the action's own action() closure so that, on + * Halt, we call $action->sendFailureNotification() ourselves before letting + * the Halt continue propagating — everything else is untouched. + * + * Fix, for refund: same notification fix, but the action() closure is + * replaced outright (not wrapped) rather than reused, because refund also + * needs a "Refund via" driver Select added to the modal (see + * fixRefundAction()) and the actual call routed through + * Payment\Support\TransactionDriverAdapter::refundVia() instead of + * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own + * docblock. + */ +class OrderRefundActionsExtension extends ViewPageExtension +{ + public function headerActions(array $actions): array + { + return array_map( + fn (Action $action) => match ($action->getName()) { + 'refund' => $this->fixRefundAction($action), + 'capture' => $this->fixFailureNotification($action), + default => $action, + }, + $actions, + ); + } + + /** + * Combines both refund-only changes on top of the failure-notification + * fix every action here gets: adds a "Refund via" driver Select + * (defaulting to the transaction's own driver) to the modal, and + * replaces the actual refund call with one that honours that field — + * calling Payment\Support\TransactionDriverAdapter::refundVia() + * directly (bypassing Lunar\Models\Transaction::refund(), whose fixed + * refund(int $amount, $notes = null) signature has no room for a + * driver override) whenever the admin picked a driver other than the + * transaction's own. When left at the default, behaviour is identical + * to calling $transaction->refund() — refundVia() resolves to the same + * driver either way. + * + * The Select is appended to Lunar's own schema closure (read via + * reflection — HasSchema::$schema has no public getter) rather than + * replacing it outright, so the transaction/amount/notes/confirm + * fields Lunar already built are untouched. + */ + private function fixRefundAction(Action $action): Action + { + $originalSchema = $this->readProtectedProperty($action, 'schema'); + + $action->schema(function (array $arguments) use ($action, $originalSchema) { + $fields = is_callable($originalSchema) + ? $action->evaluate($originalSchema, $arguments) + : ($originalSchema ?? []); + + return [ + ...$fields, + Select::make('driver') + ->label('Refund via') + ->options(fn () => $this->refundCapableDriverLabels()) + ->default(fn ($get) => $this->driverKeyForTransaction($get('transaction'))) + ->native(false) + ->required(), + ]; + }); + + return $action->action(function (array $data, Action $action) { + $transaction = Transaction::find($data['transaction']); + + if (! $transaction instanceof CoreTransaction) { + $action->failureNotification(fn () => Notification::make('refund_failure')->danger()->title('Transaction not found.')) + ->sendFailureNotification(); + + throw new Halt; + } + + $adapter = app(TransactionDriverAdapter::class); + $driverKey = $data['driver'] ?? $adapter->driverKeyFor($transaction); + + $response = $adapter->refundVia($transaction, $driverKey, (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), $data['notes'] ?? null); + + if (! $response->success) { + $action->failureNotification( + fn () => Notification::make('refund_failure')->color('danger')->title($response->message) + )->sendFailureNotification(); + + throw new Halt; + } + + $action->success(); + }); + } + + /** + * @return array + */ + private function refundCapableDriverLabels(): array + { + $registry = app(PaymentDriverRegistry::class); + + $labels = []; + + foreach ($registry->all() as $key => $driverClass) { + if (app($driverClass) instanceof SupportsRefunds) { + $labels[$key] = $registry->label($key) ?? $key; + } + } + + return $labels; + } + + private function driverKeyForTransaction(mixed $transactionId): ?string + { + if (blank($transactionId)) { + return null; + } + + $transaction = Transaction::find($transactionId); + + if (! $transaction instanceof CoreTransaction) { + return null; + } + + return app(TransactionDriverAdapter::class)->driverKeyFor($transaction); + } + + private function readProtectedProperty(object $object, string $property): mixed + { + $reflected = new ReflectionProperty($object, $property); + $reflected->setAccessible(true); + + return $reflected->getValue($object); + } + + /** + * Wraps the action's own configured action() closure so that, if it + * halts (Lunar's closures throw via $action->halt() to signal failure — + * see this class's own docblock for why that alone never sends the + * notification queued via failureNotification()), we send that + * notification ourselves before letting the Halt continue propagating + * (still needed — it's what stops callMountedAction() from treating + * this as a success and closing the modal/committing the DB transaction). + * + * $this->evaluate() (not a plain call) matches exactly how Action::call() + * itself invokes the closure — Lunar's closures type-hint $data/$record/ + * $action and rely on Filament's own container-style parameter + * resolution, not positional arguments. + */ + private function fixFailureNotification(Action $action): Action + { + $originalAction = $action->getActionFunction(); + + if ($originalAction === null) { + return $action; + } + + return $action->action(function (array $arguments) use ($action, $originalAction) { + try { + return $action->evaluate($originalAction, $arguments); + } catch (Halt $exception) { + $action->sendFailureNotification(); + + throw $exception; + } + }); + } +} diff --git a/src/Order/Filament/Extensions/OrderTransactionsExtension.php b/src/Order/Filament/Extensions/OrderTransactionsExtension.php new file mode 100644 index 0000000..d2f4390 --- /dev/null +++ b/src/Order/Filament/Extensions/OrderTransactionsExtension.php @@ -0,0 +1,33 @@ +schema([ + TransactionEntry::make('transaction_detail'), + ]); + } +} diff --git a/src/Order/Filament/Infolists/TransactionEntry.php b/src/Order/Filament/Infolists/TransactionEntry.php new file mode 100644 index 0000000..e708202 --- /dev/null +++ b/src/Order/Filament/Infolists/TransactionEntry.php @@ -0,0 +1,30 @@ +context['order_id'] to find which Order this outcome * belongs to — Payment has no concept of an Order, so this is the one @@ -28,11 +32,19 @@ use Modules\Core\Payment\Events\PaymentCaptured; * * Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set — * see that event's own docblock for why this, not CheckoutService, is now - * the dispatch point. + * the dispatch point. Never fires from the PaymentRefunded path — a + * refund can only ever happen after an order was already placed. + * + * Deliberately does NOT react to PaymentVoided — see PaymentMethod's own + * docblock for why there's no void_status column at all yet. */ class ApplyResolvedPaymentStatus { - public function handle(PaymentCaptured|PaymentAuthorized $event): void + public function __construct( + private readonly PaymentMethodCache $paymentMethods, + ) {} + + public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void { $orderId = $event->context['order_id'] ?? null; @@ -42,8 +54,13 @@ class ApplyResolvedPaymentStatus $order = Order::findOrFail($orderId); - $configKey = $event instanceof PaymentCaptured ? 'captured_status' : 'authorized_status'; - $status = config("lunar.payments.types.{$event->type}.{$configKey}"); + $method = $this->resolvePaymentMethod($event, $order); + $column = match (true) { + $event instanceof PaymentCaptured => 'captured_status', + $event instanceof PaymentAuthorized => 'authorized_status', + $event instanceof PaymentRefunded => 'refunded_status', + }; + $status = $method?->{$column}; if ($status === null) { return; @@ -56,8 +73,40 @@ class ApplyResolvedPaymentStatus 'placed_at' => $order->placed_at ?? now(), ]); - if (! $wasPlaced) { + if (! $wasPlaced && ! $event instanceof PaymentRefunded) { Event::dispatch(new OrderPlaced($order)); } } + + /** + * PaymentCaptured/PaymentAuthorized carry $event->type as the + * PaymentMethod.type that was actually charged — a direct lookup. + * + * PaymentRefunded's $event->type is the REFUND driver's own registry + * key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()), + * which may not correspond to any PaymentMethod row at all when the + * admin refunded through a different driver than the one that took + * the original payment (Payment\Support\TransactionDriverAdapter:: + * refundVia()). refunded_status is a business decision about the + * ORIGINAL payment method, not the refund mechanism, so this instead + * finds the order's earliest successful capture/intent transaction — + * the actual payment the refund is reversing — and resolves that + * transaction's own driver (a real PaymentMethod.type) instead. + */ + private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod + { + if (! $event instanceof PaymentRefunded) { + return $this->paymentMethods->all()->firstWhere('type', $event->type); + } + + $originalType = $order->transactions() + ->whereIn('type', ['capture', 'intent']) + ->where('success', true) + ->oldest('created_at') + ->value('driver'); + + return $originalType !== null + ? $this->paymentMethods->all()->firstWhere('type', $originalType) + : null; + } } diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php new file mode 100644 index 0000000..357dd5a --- /dev/null +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -0,0 +1,74 @@ + $data['notes'] ?? null]), + ); + + PaymentCaptured::dispatch($type, $result, $context); + + return $result; + } + + public function refund(string $reference, Price $amount, array $context = []): PaymentResult + { + $result = new PaymentResult( + status: PaymentResultStatus::Succeeded, + reference: 'bank-transfer-'.Str::uuid(), + amount: $amount, + meta: array_filter(['notes' => $context['notes'] ?? null]), + ); + + PaymentRefunded::dispatch('bank-transfer', $result, $context); + + return $result; + } +} diff --git a/src/Payment/Events/PaymentMethodCreated.php b/src/Payment/Events/PaymentMethodCreated.php new file mode 100644 index 0000000..092cdc0 --- /dev/null +++ b/src/Payment/Events/PaymentMethodCreated.php @@ -0,0 +1,12 @@ + $method Snapshot of the deleted row — + * already gone from the database by dispatch time, so this can't be + * a fresh PaymentMethod model instance. + */ + public function __construct( + public readonly array $method, + ) {} +} diff --git a/src/Payment/Events/PaymentMethodUpdated.php b/src/Payment/Events/PaymentMethodUpdated.php new file mode 100644 index 0000000..3e6e50b --- /dev/null +++ b/src/Payment/Events/PaymentMethodUpdated.php @@ -0,0 +1,17 @@ + $old Snapshot of the changed attributes + * before the update. + */ + public function __construct( + public readonly PaymentMethod $method, + public readonly array $old, + ) {} +} diff --git a/src/Payment/Events/PaymentMethodsReordered.php b/src/Payment/Events/PaymentMethodsReordered.php new file mode 100644 index 0000000..e327320 --- /dev/null +++ b/src/Payment/Events/PaymentMethodsReordered.php @@ -0,0 +1,17 @@ + $ids PaymentMethod ids, in their new order — + * the same array Filament's own reorderTable() already wrote to the + * database directly (bulk SQL, not PaymentMethodService::update() — + * see PaymentMethodResource's own docblock for why this is the one + * PaymentMethod write that doesn't go through the service). + */ + public function __construct( + public readonly array $ids, + ) {} +} diff --git a/src/Payment/Filament/Resources/PaymentMethodResource.php b/src/Payment/Filament/Resources/PaymentMethodResource.php index d495326..1f6842c 100644 --- a/src/Payment/Filament/Resources/PaymentMethodResource.php +++ b/src/Payment/Filament/Resources/PaymentMethodResource.php @@ -3,21 +3,57 @@ namespace Modules\Core\Payment\Filament\Resources; use Filament\Actions\Action; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; +use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Table; +use Illuminate\Support\Facades\Event; +use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Models\PaymentMethod; +use Modules\Core\Payment\Services\PaymentDriverRegistry; +use Modules\Core\Payment\Services\PaymentMethodCache; +use Modules\Core\Payment\Services\PaymentMethodService; /** - * 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. + * The DB-instance layer for Payment (see docs/payments.md) — admin + * creatable/deletable, same as Lunar's own ShippingMethodResource. A row's + * `driver` is picked from a Select populated by + * PaymentDriverRegistry::labels() (mirrors Modules\Core\Shipping\ + * Extensions\ShippingMethodResourceExtension::driverSelect()'s use of + * Shipping::getSupportedDrivers()), not a hardcoded options list, and + * never the raw driver class name — a third-party driver registered from + * its own package's service provider shows up here with no change to + * this class. + * + * Every write goes through Modules\Core\Payment\Services\ + * PaymentMethodService — create/edit/delete/the enabled toggle all call + * it, not PaymentMethod::create()/update()/delete() directly, so cache + * invalidation and event dispatch happen in one place. The ONE exception + * is drag-to-reorder: Filament's own reorderTable() always writes the new + * `position` values via its own raw bulk SQL query before our + * afterReordering() hook ever runs — there is no seam to route that + * specific write through the service (short of disabling drag-reorder + * entirely and rebuilding it from scratch), so that hook only forgets the + * cache and dispatches PaymentMethodsReordered; the data itself is + * already correct in the database by the time it fires. + * + * `driver_missing_at` (set by the `boboko:payment:sync-drivers` command + * when a row's driver no longer resolves) is surfaced as its own table + * column, deliberately distinct from `enabled` — an admin needs to tell + * "I turned this off" apart from "the driver code was removed" at a + * glance, not have both look like the same disabled state. + * + * `authorized_status` only appears in the form when `capture_mode` is + * "Hold now, charge later" — it's simply unreachable for a "Charge + * immediately" method (that mode only ever produces PaymentCaptured, + * never PaymentAuthorized), so showing it unconditionally would just be + * a confusing, always-irrelevant field for most methods. */ class PaymentMethodResource extends Resource { @@ -35,10 +71,31 @@ class PaymentMethodResource extends Resource { return $table ->columns([ + TextColumn::make('position') + ->label('Order') + ->sortable(), + TextColumn::make('name') + ->label('Name') + ->searchable(), TextColumn::make('type') ->label('Type'), + TextColumn::make('driver') + ->label('Driver') + ->formatStateUsing(fn (?string $state) => static::driverLabel($state)), + IconColumn::make('driver_missing_at') + ->label('Driver status') + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-check-circle') + ->trueColor('danger') + ->falseColor('success') + ->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at + ? 'Driver not found as of '.$record->driver_missing_at->diffForHumans() + : 'Driver resolves correctly'), ToggleColumn::make('enabled') - ->label('Enabled'), + ->label('Enabled') + ->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class) + ->update($record, ['enabled' => $state])), TextColumn::make('data.fee') ->label('Fee') ->formatStateUsing(fn (?int $state) => $state @@ -48,10 +105,110 @@ class PaymentMethodResource extends Resource ->label('Last updated') ->dateTime(), ]) + ->reorderable('position') + ->afterReordering(function (array $order) { + app(PaymentMethodCache::class)->forget(); + + Event::dispatch(new PaymentMethodsReordered(array_map('intval', array_values($order)))); + }) ->recordActions([ + static::editAction(), static::editFeeAction(), + static::deleteAction(), ]) - ->defaultSort('type'); + ->defaultSort('position'); + } + + /** + * @return array + */ + public static function getFormComponents(): array + { + return [ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + TextInput::make('type') + ->label('Type') + ->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + static::getDriverFormComponent(), + Select::make('capture_mode') + ->label('Capture mode') + ->helperText('Whether checkout charges immediately, or places a hold to settle later.') + ->options([ + 'pay' => 'Charge immediately', + 'authorize' => 'Hold now, charge later', + ]) + ->default('pay') + ->live() + ->required(), + static::getOrderStatusSelect('captured_status', 'Order status once paid') + ->helperText('Applied the moment a payment is fully charged.'), + static::getOrderStatusSelect('authorized_status', 'Order status once held') + ->helperText('Applied the moment a hold is placed, before it\'s charged.') + ->visible(fn (Get $get) => $get('capture_mode') === 'authorize'), + static::getOrderStatusSelect('refunded_status', 'Order status once refunded') + ->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'), + ]; + } + + public static function getDriverFormComponent(): Component + { + return Select::make('driver') + ->label('Driver') + ->options(fn () => app(PaymentDriverRegistry::class)->labels()) + ->required(); + } + + /** + * Lunar's own Order::status is a plain, admin-extensible string + * (config('lunar.orders.statuses')) rather than a fixed enum — + * deliberately so a store can add its own custom status without a + * code change (see docs/payments.md). This Select still reads from + * that same open-ended list, just so an admin picks a real status + * instead of typing a slug from memory. + */ + private static function getOrderStatusSelect(string $name, string $label): Select + { + return Select::make($name) + ->label($label) + ->options(collect(config('lunar.orders.statuses', [])) + ->map(fn (array $status) => $status['label'] ?? $status) + ->all()) + ->native(false); + } + + public static function getPages(): array + { + return [ + 'index' => ListPaymentMethods::route('/'), + ]; + } + + public static function canCreate(): bool + { + return true; + } + + public static function canDelete($record = null): bool + { + return true; + } + + private static function editAction(): Action + { + return Action::make('edit') + ->label('Edit') + ->icon('heroicon-o-pencil-square') + ->schema(static::getFormComponents()) + ->fillForm(fn (PaymentMethod $record) => $record->only([ + 'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status', + ])) + ->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data)); } /** @@ -76,7 +233,7 @@ class PaymentMethodResource extends Resource 'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null, ]) ->action(function (PaymentMethod $record, array $data) { - $record->update([ + app(PaymentMethodService::class)->update($record, [ 'data' => [ ...$record->data->toArray(), 'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null, @@ -85,20 +242,22 @@ class PaymentMethodResource extends Resource }); } - public static function getPages(): array + private static function deleteAction(): Action { - return [ - 'index' => ListPaymentMethods::route('/'), - ]; + return Action::make('delete') + ->label('Delete') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(fn (PaymentMethod $record) => app(PaymentMethodService::class)->delete($record)); } - public static function canCreate(): bool + private static function driverLabel(?string $key): string { - return false; - } + if ($key === null) { + return '—'; + } - public static function canDelete($record = null): bool - { - return false; + return app(PaymentDriverRegistry::class)->label($key) ?? $key; } } diff --git a/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php b/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php index 90edd3b..5e318c2 100644 --- a/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php +++ b/src/Payment/Filament/Resources/PaymentMethodResource/Pages/ListPaymentMethods.php @@ -2,10 +2,31 @@ namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages; +use Filament\Actions; use Filament\Resources\Pages\ListRecords; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; +use Modules\Core\Payment\Models\PaymentMethod; +use Modules\Core\Payment\Services\PaymentMethodService; class ListPaymentMethods extends ListRecords { protected static string $resource = PaymentMethodResource::class; + + protected function getHeaderActions(): array + { + return [ + Actions\CreateAction::make() + ->schema(PaymentMethodResource::getFormComponents()) + ->fillForm(fn () => [ + 'position' => (PaymentMethod::max('position') ?? 0) + 1, + 'enabled' => false, + 'data' => [], + ]) + // Every PaymentMethod write goes through PaymentMethodService + // — see PaymentMethodResource's own docblock — so this + // replaces CreateAction's default $model::create($data), not + // just the form/fill behavior above. + ->using(fn (array $data) => app(PaymentMethodService::class)->create($data)), + ]; + } } diff --git a/src/Payment/Http/Controllers/StripeWebhookController.php b/src/Payment/Http/Controllers/StripeWebhookController.php index 1dfbe5a..f60c534 100644 --- a/src/Payment/Http/Controllers/StripeWebhookController.php +++ b/src/Payment/Http/Controllers/StripeWebhookController.php @@ -23,7 +23,7 @@ use Stripe\Webhook; * anywhere reusable, it only gates the request through. * * Resolves the driver directly by class, not via - * Modules\Core\Payment\Services\PaymentDriverResolver — this endpoint is + * Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is * inherently Stripe-specific (Stripe's own webhook payload carries no * boboko payment-type key, only its own payment_intent id), and * StripePaymentDriver::handleCallback() already recovers $type itself diff --git a/src/Payment/Listeners/LogPaymentMethodActivity.php b/src/Payment/Listeners/LogPaymentMethodActivity.php new file mode 100644 index 0000000..dea4871 --- /dev/null +++ b/src/Payment/Listeners/LogPaymentMethodActivity.php @@ -0,0 +1,60 @@ +activityLog->created($event->method, $event->method->getAttributes()); + } + + public function handleUpdated(PaymentMethodUpdated $event): void + { + $this->activityLog->updated( + $event->method, + $event->old, + $event->method->only(array_keys($event->old)), + ); + } + + public function handleDeleted(PaymentMethodDeleted $event): void + { + $subject = (new PaymentMethod)->forceFill($event->method); + $subject->exists = true; + $subject->id = $event->method['id']; + + $this->activityLog->deleted($subject, $event->method); + } +} diff --git a/src/Payment/Models/CoreTransaction.php b/src/Payment/Models/CoreTransaction.php new file mode 100644 index 0000000..af26e79 --- /dev/null +++ b/src/Payment/Models/CoreTransaction.php @@ -0,0 +1,32 @@ +transactions, and everything the admin + * panel's refund/capture actions call on one of those rows, silently run + * through our own system. + * + * Only driver() is overridden — refund()/capture()/paymentChecks() on the + * parent class all just call driver()->{method}(), so replacing what + * driver() returns is the entire fix (see TransactionDriverAdapter). + */ +class CoreTransaction extends Transaction +{ + public function driver(): TransactionDriverAdapter + { + return app(TransactionDriverAdapter::class); + } +} diff --git a/src/Payment/Models/PaymentMethod.php b/src/Payment/Models/PaymentMethod.php index 3fbbfd1..cec62f1 100644 --- a/src/Payment/Models/PaymentMethod.php +++ b/src/Payment/Models/PaymentMethod.php @@ -6,17 +6,35 @@ 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. + * A merchant-configured payment method — the DB-instance layer, admin + * creatable/deletable, same split Modules\Core\Shipping's own + * shipping_methods table already has (see docs/payments.md): + * - type: unique, machine-facing slug (Cart::meta['payment_method'], + * ApplyCashOnDeliveryFee's lookup key, every Payment event's $type). + * - name: admin-facing label. + * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key + * — NOT the same as `type`, and not unique (two rows can share one + * driver, e.g. two differently-named offline-style methods). + * - capture_mode: 'pay' or 'authorize' — which SupportsPay/ + * SupportsAuthorization method CheckoutService::initiatePayment() + * calls for this row. + * - captured_status / authorized_status / refunded_status: the + * Order::status value Modules\Core\Order\Listeners\ + * ApplyResolvedPaymentStatus applies on a PaymentCaptured/ + * PaymentAuthorized/PaymentRefunded event. For a refund, this is + * always the ORIGINAL payment method's row (the one the customer + * actually paid with), never the driver the refund itself was routed + * through (Payment\Support\TransactionDriverAdapter::refundVia() may + * use a different one entirely — e.g. a cash-on-delivery order + * refunded via a Bank Transfer driver with no PaymentMethod row of + * its own) — see that listener's own docblock. + * - position: admin-controlled display/checkout order. + * - driver_missing_at: set by `payment:sync-drivers` when `driver` no + * longer resolves via the registry — separate from `enabled`, so a + * driver vanishing (a deploy removed it) is never confused with an + * admin's own manual toggle. + * - data: jsonb, driver-specific settings that don't warrant their own + * column (starts with 'fee', the offline flat surcharge). */ class PaymentMethod extends Model { @@ -24,6 +42,8 @@ class PaymentMethod extends Model protected $casts = [ 'enabled' => 'boolean', + 'position' => 'integer', + 'driver_missing_at' => 'datetime', 'data' => AsArrayObject::class, ]; } diff --git a/src/Payment/Services/PaymentDriverRegistry.php b/src/Payment/Services/PaymentDriverRegistry.php new file mode 100644 index 0000000..4a00663 --- /dev/null +++ b/src/Payment/Services/PaymentDriverRegistry.php @@ -0,0 +1,107 @@ + + */ + private array $drivers = []; + + /** + * @var array + */ + private array $labels = []; + + /** + * $key is the registry key a Modules\Core\Payment\Models\PaymentMethod + * row's own `driver` column stores — NOT the same as that row's `type` + * (its merchant-facing slug). Two rows can share one driver key (e.g. + * both 'cash-on-delivery' and 'cash-in-hand' using the same 'offline' + * driver with different type/name/fee). + * + * $label is a short, human-readable name (e.g. "Stripe", "Offline / + * Manual") — this is where that comes from, not $driverClass's own + * FQCN. Payment's driver classes implement several independent, + * opt-in capability interfaces (Configurable, SupportsPay, ...), none + * of which carries a display name the way Lunar\Shipping\Interfaces\ + * ShippingRateInterface::name() does for every shipping driver — the + * registry is the one place that DOES know every driver at once, so + * it's the natural (and only) place to also hold this. + */ + public function register(string $key, string $driverClass, string $label): void + { + $this->drivers[$key] = $driverClass; + $this->labels[$key] = $label; + } + + /** + * Null if $key was never registered — deliberately non-throwing, same + * reasoning the old PaymentDriverResolver already had: a caller + * checking availability (or the payment:sync-drivers command checking + * every PaymentMethod row) needs "not found" to be a normal, silent + * result, not an exception to catch. + */ + public function resolve(string $key): ?object + { + $driverClass = $this->drivers[$key] ?? null; + + return $driverClass ? app($driverClass) : null; + } + + /** + * Every registered key => driver class — what payment:sync-drivers + * checks every PaymentMethod row's `driver` column against. + * + * @return array + */ + public function all(): array + { + return $this->drivers; + } + + /** + * Every registered key => human-readable label — what a Filament + * driver Select populates its options from (mirroring + * ShippingMethodResourceExtension::driverSelect()'s use of + * Shipping::getSupportedDrivers(), which reads each driver's own + * name()) — never the raw class name from all(). + * + * @return array + */ + public function labels(): array + { + return $this->labels; + } + + public function label(string $key): ?string + { + return $this->labels[$key] ?? null; + } +} diff --git a/src/Payment/Services/PaymentDriverResolver.php b/src/Payment/Services/PaymentDriverResolver.php deleted file mode 100644 index da935d3..0000000 --- a/src/Payment/Services/PaymentDriverResolver.php +++ /dev/null @@ -1,34 +0,0 @@ -') at all — deliberately - * non-throwing so a caller like CheckoutService::getPaymentMethods() - * can filter unresolvable types silently rather than treating "not - * registered" as an error condition when just checking availability. - */ - public function resolve(string $type): ?object - { - $driverClass = config("lunar.payments.types.{$type}.payment_driver"); - - return $driverClass ? app($driverClass) : null; - } -} diff --git a/src/Payment/Services/PaymentMethodCache.php b/src/Payment/Services/PaymentMethodCache.php new file mode 100644 index 0000000..99a4750 --- /dev/null +++ b/src/Payment/Services/PaymentMethodCache.php @@ -0,0 +1,41 @@ + PaymentMethod::query()->orderBy('position')->get(), + ); + } + + public function forget(): void + { + Cache::forget(self::CACHE_KEY); + } +} diff --git a/src/Payment/Services/PaymentMethodService.php b/src/Payment/Services/PaymentMethodService.php new file mode 100644 index 0000000..c6c5853 --- /dev/null +++ b/src/Payment/Services/PaymentMethodService.php @@ -0,0 +1,83 @@ + + */ + public function list(): Collection + { + return $this->cache->all(); + } + + /** + * @param array $data + */ + public function create(array $data): PaymentMethod + { + $method = PaymentMethod::create($data); + + $this->cache->forget(); + + Event::dispatch(new PaymentMethodCreated($method)); + + return $method; + } + + /** + * @param array $data + */ + public function update(PaymentMethod $method, array $data): PaymentMethod + { + $old = $method->only(array_keys($data)); + + $method->update($data); + + $this->cache->forget(); + + Event::dispatch(new PaymentMethodUpdated($method, $old)); + + return $method; + } + + public function delete(PaymentMethod $method): void + { + $snapshot = $method->only([ + 'id', 'type', 'name', 'driver', 'capture_mode', + 'captured_status', 'authorized_status', 'position', 'enabled', + ]); + + $method->delete(); + + $this->cache->forget(); + + Event::dispatch(new PaymentMethodDeleted($snapshot)); + } +} diff --git a/src/Payment/Support/TransactionDriverAdapter.php b/src/Payment/Support/TransactionDriverAdapter.php new file mode 100644 index 0000000..24c8c02 --- /dev/null +++ b/src/Payment/Support/TransactionDriverAdapter.php @@ -0,0 +1,144 @@ +driver) — the point + * where every Lunar-native caller of a transaction's driver (today: the + * admin panel's "Refund"/"Capture" header actions on the order page, + * ManageOrder::getRefundAction()/getCaptureAction() — see + * $transaction->refund()/->capture() in vendor/lunarphp/core/src/Models/ + * Transaction.php) transparently lands on OUR real payment system instead + * of Lunar's own, entirely separate, unused PaymentManager. + * + * Implements Lunar\Base\PaymentTypeInterface's refund()/capture()/ + * getPaymentChecks() signatures exactly — each takes the Transaction as + * its own first argument (confirmed from vendor/lunarphp/core/src/Models/ + * Transaction.php: `$this->driver()->refund($this, $amount, $notes)`), + * so this class holds no transaction state of its own; CoreTransaction's + * driver() can return one shared instance for any transaction. + * + * $transaction->driver is a Modules\Core\Payment\Models\PaymentMethod.type + * value (what Modules\Core\Order\Services\TransactionRecorder writes into + * Transaction.driver) — this resolves the REAL registry key from that + * type via PaymentMethodCache, then the real driver instance from + * PaymentDriverRegistry, so refund()/capture() called here call the + * ACTUAL Stripe/etc. driver, never a fake/no-op stand-in. If either + * lookup fails (the PaymentMethod row or its driver no longer exists), + * refund()/capture() report failure rather than silently doing nothing. + */ +class TransactionDriverAdapter +{ + public function __construct( + private readonly PaymentMethodCache $paymentMethods, + private readonly PaymentDriverRegistry $registry, + ) {} + + public function refund(Transaction $transaction, int $amount, ?string $notes = null): PaymentRefund + { + return $this->refundVia($transaction, $this->driverKeyFor($transaction), $amount, $notes); + } + + /** + * The PaymentDriverRegistry key $transaction was originally taken + * through — what refund()/capture() resolve against by default, and + * what Order\Filament\Extensions\OrderRefundActionsExtension defaults + * its "Refund via" driver Select to, before an admin overrides it. + */ + public function driverKeyFor(Transaction $transaction): ?string + { + return $this->paymentMethods->all()->firstWhere('type', $transaction->driver)?->driver; + } + + /** + * Same as refund(), but against an explicitly chosen driver rather than + * the one $transaction was originally taken through — e.g. refunding a + * cash-on-delivery order via a Bank Transfer driver instead of trying + * (and failing) to refund through the offline driver that took the + * original payment. $driverKey is a PaymentDriverRegistry key (e.g. + * 'bank-transfer'), not a PaymentMethod.type — the two only coincide + * when refunding through the transaction's own original driver. + * + * Called directly by Order\Filament\Extensions\ + * OrderRefundActionsExtension when the admin picks a different driver + * in the refund modal, bypassing Lunar\Models\Transaction::refund() + * (whose fixed refund(int $amount, $notes = null) signature has no + * room for a driver override) — see that extension's own docblock. + */ + public function refundVia(Transaction $transaction, ?string $driverKey, int $amount, ?string $notes = null): PaymentRefund + { + $driver = $driverKey !== null ? $this->registry->resolve($driverKey) : null; + + if (! $driver instanceof SupportsRefunds) { + return new PaymentRefund(success: false, message: 'This payment method does not support refunds.'); + } + + $result = $driver->refund( + $transaction->reference, + $this->priceFor($transaction, $amount), + ['notes' => $notes, 'order_id' => $transaction->order_id], + ); + + return new PaymentRefund( + success: $result->status === PaymentResultStatus::Succeeded, + message: $result->failureReason, + ); + } + + public function capture(Transaction $transaction, int $amount = 0): PaymentCapture + { + $driver = $this->resolveDriver($transaction); + + if (! $driver instanceof SupportsCaptures) { + return new PaymentCapture(success: false, message: 'This payment method does not support a separate capture step.'); + } + + $result = $driver->capture( + $transaction->reference, + $this->priceFor($transaction, $amount ?: $transaction->amount->value), + ['order_id' => $transaction->order_id], + ); + + return new PaymentCapture( + success: $result->status === PaymentResultStatus::Succeeded, + message: $result->failureReason ?? '', + ); + } + + /** + * Lunar's own PaymentChecks DTO (address/postcode/CVC verification + * results) has no equivalent in our own contracts — none of our + * drivers currently surface this level of gateway-specific detail. + * Empty, not null: Lunar's admin panel iterates this collection to + * render a checks list, so it needs to always be a valid (possibly + * empty) PaymentChecks, never missing entirely. + */ + public function getPaymentChecks(Transaction $transaction): PaymentChecks + { + return new PaymentChecks; + } + + private function resolveDriver(Transaction $transaction): ?object + { + $driverKey = $this->driverKeyFor($transaction); + + return $driverKey !== null ? $this->registry->resolve($driverKey) : null; + } + + private function priceFor(Transaction $transaction, int $amount): Price + { + return new Price($amount, $transaction->order->currency); + } +} diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php index 3bb84a7..764847c 100644 --- a/src/Providers/OrderServiceProvider.php +++ b/src/Providers/OrderServiceProvider.php @@ -36,6 +36,7 @@ class OrderServiceProvider extends ServiceProvider Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class); + Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class); Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class); Event::listen(PaymentVoided::class, RecordPaymentTransaction::class); diff --git a/src/Providers/PaymentServiceProvider.php b/src/Providers/PaymentServiceProvider.php index 3beca72..bf36462 100644 --- a/src/Providers/PaymentServiceProvider.php +++ b/src/Providers/PaymentServiceProvider.php @@ -2,24 +2,37 @@ namespace Modules\Core\Providers; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lunar\Facades\ModelManifest; +use Lunar\Models\Contracts\Transaction as TransactionContract; use Lunar\Pipelines\Cart\ApplyShipping; +use Modules\Core\Command\SyncPaymentDriversCommand; +use Modules\Core\Payment\Drivers\BankTransferPaymentDriver; +use Modules\Core\Payment\Drivers\OfflinePaymentDriver; +use Modules\Core\Payment\Drivers\StripePaymentDriver; +use Modules\Core\Payment\Events\PaymentMethodCreated; +use Modules\Core\Payment\Events\PaymentMethodDeleted; +use Modules\Core\Payment\Events\PaymentMethodUpdated; +use Modules\Core\Payment\Listeners\LogPaymentMethodActivity; +use Modules\Core\Payment\Models\CoreTransaction; +use Modules\Core\Payment\Services\PaymentDriverRegistry; class PaymentServiceProvider extends ServiceProvider { public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment'); + + $this->app->singleton(PaymentDriverRegistry::class); } public function boot(): void { - config([ - 'lunar.payments.types' => array_merge( - config('lunar.payments.types', []), - config('payment.types', []) - ), - ]); + $registry = $this->app->make(PaymentDriverRegistry::class); + $registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual'); + $registry->register('stripe', StripePaymentDriver::class, 'Stripe'); + $registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer'); $cartPipeline = config('lunar.cart.pipelines.cart', []); $insertAfter = array_search(ApplyShipping::class, $cartPipeline, true); @@ -39,6 +52,27 @@ class PaymentServiceProvider extends ServiceProvider config(['lunar.cart.pipelines.cart' => $cartPipeline]); + // Same contract-swap mechanism this codebase already uses for + // Customer/Staff (see e.g. consuming apps' own AppServiceProvider, + // ModelManifest::replace(Contracts\Customer::class, ...)) — every + // place Lunar's own code resolves a transaction via + // Transaction::modelClass() (Order::transactions()'s own relation + // included) gets Modules\Core\Payment\Models\CoreTransaction + // instead of the vendor's own Transaction. That subclass's + // driver() override is the ENTIRE fix for the admin panel's + // refund/capture actions silently landing on our real payment + // system — see CoreTransaction's own docblock. Lunar's own + // Payments facade/PaymentManager is never touched at all. + ModelManifest::replace(TransactionContract::class, CoreTransaction::class); + + Event::listen(PaymentMethodCreated::class, [LogPaymentMethodActivity::class, 'handleCreated']); + Event::listen(PaymentMethodUpdated::class, [LogPaymentMethodActivity::class, 'handleUpdated']); + Event::listen(PaymentMethodDeleted::class, [LogPaymentMethodActivity::class, 'handleDeleted']); + $this->loadRoutesFrom(__DIR__ . '/../Payment/routes/webhooks.php'); + + if ($this->app->runningInConsole()) { + $this->commands([SyncPaymentDriversCommand::class]); + } } }