From a5f3008ce289d1934a75d13a398335dea8d4056e Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:17:56 +0300 Subject: [PATCH] Fix: Update Order status to Processing when payment has been recieved --- src/CorePlugin.php | 4 +- ...xtension.php => OrderActionsExtension.php} | 89 +++++++++++-------- .../Extensions/OrderItemsTableExtension.php | 2 +- .../Listeners/ApplyResolvedPaymentStatus.php | 37 ++++++-- .../Drivers/BankTransferPaymentDriver.php | 2 +- src/Payment/Drivers/StripePaymentDriver.php | 1 - .../Support/TransactionDriverAdapter.php | 4 +- 7 files changed, 86 insertions(+), 53 deletions(-) rename src/Order/Filament/Extensions/{OrderRefundActionsExtension.php => OrderActionsExtension.php} (74%) diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 31efae4..33a0b55 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -28,7 +28,7 @@ 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\OrderPaymentMethodSummaryExtension; -use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension; +use Modules\Core\Order\Filament\Extensions\OrderActionsExtension; use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; @@ -70,7 +70,7 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], + ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], OrderItemsTable::class => OrderItemsTableExtension::class, ]); diff --git a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php b/src/Order/Filament/Extensions/OrderActionsExtension.php similarity index 74% rename from src/Order/Filament/Extensions/OrderRefundActionsExtension.php rename to src/Order/Filament/Extensions/OrderActionsExtension.php index 39639d2..d90a11c 100644 --- a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php +++ b/src/Order/Filament/Extensions/OrderActionsExtension.php @@ -32,10 +32,6 @@ use ReflectionProperty; * 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 @@ -43,15 +39,28 @@ use ReflectionProperty; * Payment\Support\TransactionDriverAdapter::refundVia() instead of * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own * docblock. + * + * Fix, for capture: same notification fix, but the action() closure is + * also replaced outright — the actual call is routed through + * Payment\Support\TransactionDriverAdapter::capture() instead of + * Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a + * manual backoffice capture goes through the app's own payment driver + * registry and dispatches Payment\Events\PaymentCaptured exactly like a + * checkout-time capture does — the vendor path resolved + * Lunar\Facades\Payments (an entirely separate, unused driver registry) + * and never dispatched that event, which is why Order::status used to + * stay stuck on 'awaiting_payment' after a manual capture even though + * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it + * on PaymentCaptured. */ -class OrderRefundActionsExtension extends ViewPageExtension +class OrderActionsExtension 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), + 'capture' => $this->fixCaptureAction($action), default => $action, }, $actions, @@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension }); } + /** + * Mirrors fixRefundAction()'s notification fix, but for the "amount" + * field already on the vendor schema — no extra field needed, since + * capture always goes back through the transaction's own original + * driver (there's no equivalent to refunding via a different driver). + */ + private function fixCaptureAction(Action $action): Action + { + return $action->action(function (array $data, Action $action) { + $transaction = Transaction::find($data['transaction']); + + if (! $transaction instanceof CoreTransaction) { + $action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.')) + ->sendFailureNotification(); + + throw new Halt; + } + + $response = app(TransactionDriverAdapter::class)->capture( + $transaction, + (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), + ); + + if (! $response->success) { + $action->failureNotification( + fn () => Notification::make('capture_failure')->color('danger')->title($response->message) + )->sendFailureNotification(); + + throw new Halt; + } + + $action->success(); + }); + } + /** * @return array */ @@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension 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/OrderItemsTableExtension.php b/src/Order/Filament/Extensions/OrderItemsTableExtension.php index 8fda303..7a8bcc8 100644 --- a/src/Order/Filament/Extensions/OrderItemsTableExtension.php +++ b/src/Order/Filament/Extensions/OrderItemsTableExtension.php @@ -8,7 +8,7 @@ use Filament\Tables\Table; use Lunar\Admin\Support\Extending\BaseExtension; /** - * Same fix as OrderRefundActionsExtension, applied to the order lines + * Same fix as OrderActionsExtension, applied to the order lines * table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable:: * getBulkRefundAction()) — see that class's docblock for the underlying * Filament bug (failureNotification()+failure()+halt() never actually diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php index 2df32a2..28d6524 100644 --- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event; use Lunar\Models\Order; use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Order\Enums\PaymentStatus; +use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Payment\Events\PaymentAuthorized; @@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded; * Registered against PaymentCaptured, PaymentAuthorized, AND * PaymentRefunded (see OrderServiceProvider). * - * A capture/authorization only ever writes Order::paid/paid_at (via - * OrderStatusWriter::markPaid()) — never `status`. Confirmed with the - * user: status leaving 'awaiting_payment' is always a staff-driven - * "Update Status" click, regardless of payment method — no special-casing - * prepaid vs. cash-on-delivery. A prepaid order briefly sitting at - * 'awaiting_payment' with paid = true (until staff notice and advance it) - * is expected, not a bug. + * PaymentCaptured writes both Order::paid/paid_at (via + * OrderStatusWriter::markPaid()) AND advances `status` out of + * 'awaiting_payment' to the next step in the order's flow (see + * OrderStatusFlow::nextOptions()) — re-confirmed with the user: a + * captured payment, manual or via Stripe's webhook, should never leave an + * order sitting at 'awaiting_payment'. Only fires when status is still + * exactly 'awaiting_payment', so a duplicate/delayed capture event never + * regresses an order staff already advanced further. PaymentAuthorized + * only marks paid — an authorization is not yet captured funds, so + * status stays put until the actual capture. * * A refund still moves `status` (returned -> refunded/partially_refunded) * — refunds are a normal step in Modules\Core\Order\Services\ @@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus { public function __construct( private readonly OrderStatusWriter $writer, + private readonly OrderStatusFlow $flow, ) {} public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void @@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus $this->writer->markPaid($order, $event::class); + if ($event instanceof PaymentCaptured) { + $this->advancePastAwaitingPayment($order, $event); + } + if (! $wasPlaced) { $order->update(['placed_at' => $order->placed_at ?? now()]); Event::dispatch(new OrderPlaced($order)); } } + private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void + { + if ($order->status !== 'awaiting_payment') { + return; + } + + $next = $this->flow->nextOptions($order); + $target = array_key_first($next); + + if ($target !== null) { + $this->writer->write($order, $target, $event::class); + } + } + /** * Requires the refund Transaction row to already exist (Modules\Core\ * Order\Listeners\RecordPaymentTransaction must run first — see diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php index 357dd5a..da8ab1f 100644 --- a/src/Payment/Drivers/BankTransferPaymentDriver.php +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded; * chooses this driver explicitly in the refund action, independent of * which driver the original payment went through (see * Payment\Support\TransactionDriverAdapter::refundVia() and - * Order\Filament\Extensions\OrderRefundActionsExtension). pay() exists so + * Order\Filament\Extensions\OrderActionsExtension). pay() exists so * the same driver also covers receiving a payment by bank transfer, but * the admin UI for that (bank reference, notes, proof-of-transfer upload) * is deliberately not built yet — see the follow-up work tracked from this diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index fdfd58a..f3eadc9 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -70,7 +70,6 @@ class StripePaymentDriver implements { return filled(config('services.stripe.key')); } - /** * Atomic charge — capture_method: automatic. Stripe still frequently * confirms into requires_action/requires_confirmation rather than diff --git a/src/Payment/Support/TransactionDriverAdapter.php b/src/Payment/Support/TransactionDriverAdapter.php index 24c8c02..4496a55 100644 --- a/src/Payment/Support/TransactionDriverAdapter.php +++ b/src/Payment/Support/TransactionDriverAdapter.php @@ -54,7 +54,7 @@ class TransactionDriverAdapter /** * The PaymentDriverRegistry key $transaction was originally taken * through — what refund()/capture() resolve against by default, and - * what Order\Filament\Extensions\OrderRefundActionsExtension defaults + * what Order\Filament\Extensions\OrderActionsExtension defaults * its "Refund via" driver Select to, before an admin overrides it. */ public function driverKeyFor(Transaction $transaction): ?string @@ -72,7 +72,7 @@ class TransactionDriverAdapter * when refunding through the transaction's own original driver. * * Called directly by Order\Filament\Extensions\ - * OrderRefundActionsExtension when the admin picks a different driver + * OrderActionsExtension 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.