Fix: Update Order status to Processing when payment has been recieved

This commit is contained in:
2026-09-15 21:17:56 +03:00
parent d9fb3bbde6
commit a5f3008ce2
7 changed files with 86 additions and 53 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension; use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension; 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\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
@@ -70,7 +70,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::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, OrderItemsTable::class => OrderItemsTableExtension::class,
]); ]);
@@ -32,10 +32,6 @@ use ReflectionProperty;
* could return a real, honest failure — see Payment\Support\ * could return a real, honest failure — see Payment\Support\
* TransactionDriverAdapter's own docblock for that history. * 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 * Fix, for refund: same notification fix, but the action() closure is
* replaced outright (not wrapped) rather than reused, because refund also * replaced outright (not wrapped) rather than reused, because refund also
* needs a "Refund via" driver Select added to the modal (see * needs a "Refund via" driver Select added to the modal (see
@@ -43,15 +39,28 @@ use ReflectionProperty;
* Payment\Support\TransactionDriverAdapter::refundVia() instead of * Payment\Support\TransactionDriverAdapter::refundVia() instead of
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
* docblock. * 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 public function headerActions(array $actions): array
{ {
return array_map( return array_map(
fn (Action $action) => match ($action->getName()) { fn (Action $action) => match ($action->getName()) {
'refund' => $this->fixRefundAction($action), 'refund' => $this->fixRefundAction($action),
'capture' => $this->fixFailureNotification($action), 'capture' => $this->fixCaptureAction($action),
default => $action, default => $action,
}, },
$actions, $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<string, string> * @return array<string, string>
*/ */
@@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension
return $reflected->getValue($object); 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;
}
});
}
} }
@@ -8,7 +8,7 @@ use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\BaseExtension; 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:: * table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
* getBulkRefundAction()) — see that class's docblock for the underlying * getBulkRefundAction()) — see that class's docblock for the underlying
* Filament bug (failureNotification()+failure()+halt() never actually * Filament bug (failureNotification()+failure()+halt() never actually
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus; use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
@@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* Registered against PaymentCaptured, PaymentAuthorized, AND * Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider). * PaymentRefunded (see OrderServiceProvider).
* *
* A capture/authorization only ever writes Order::paid/paid_at (via * PaymentCaptured writes both Order::paid/paid_at (via
* OrderStatusWriter::markPaid()) — never `status`. Confirmed with the * OrderStatusWriter::markPaid()) AND advances `status` out of
* user: status leaving 'awaiting_payment' is always a staff-driven * 'awaiting_payment' to the next step in the order's flow (see
* "Update Status" click, regardless of payment method — no special-casing * OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
* prepaid vs. cash-on-delivery. A prepaid order briefly sitting at * captured payment, manual or via Stripe's webhook, should never leave an
* 'awaiting_payment' with paid = true (until staff notice and advance it) * order sitting at 'awaiting_payment'. Only fires when status is still
* is expected, not a bug. * 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) * A refund still moves `status` (returned -> refunded/partially_refunded)
* — refunds are a normal step in Modules\Core\Order\Services\ * — refunds are a normal step in Modules\Core\Order\Services\
@@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus
$this->writer->markPaid($order, $event::class); $this->writer->markPaid($order, $event::class);
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) { if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]); $order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order)); 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\ * Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see * Order\Listeners\RecordPaymentTransaction must run first — see
@@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* chooses this driver explicitly in the refund action, independent of * chooses this driver explicitly in the refund action, independent of
* which driver the original payment went through (see * which driver the original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and * 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 same driver also covers receiving a payment by bank transfer, but
* the admin UI for that (bank reference, notes, proof-of-transfer upload) * 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 * is deliberately not built yet — see the follow-up work tracked from this
@@ -70,7 +70,6 @@ class StripePaymentDriver implements
{ {
return filled(config('services.stripe.key')); return filled(config('services.stripe.key'));
} }
/** /**
* Atomic charge — capture_method: automatic. Stripe still frequently * Atomic charge — capture_method: automatic. Stripe still frequently
* confirms into requires_action/requires_confirmation rather than * confirms into requires_action/requires_confirmation rather than
@@ -54,7 +54,7 @@ class TransactionDriverAdapter
/** /**
* The PaymentDriverRegistry key $transaction was originally taken * The PaymentDriverRegistry key $transaction was originally taken
* through — what refund()/capture() resolve against by default, and * 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. * its "Refund via" driver Select to, before an admin overrides it.
*/ */
public function driverKeyFor(Transaction $transaction): ?string public function driverKeyFor(Transaction $transaction): ?string
@@ -72,7 +72,7 @@ class TransactionDriverAdapter
* when refunding through the transaction's own original driver. * when refunding through the transaction's own original driver.
* *
* Called directly by Order\Filament\Extensions\ * 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() * in the refund modal, bypassing Lunar\Models\Transaction::refund()
* (whose fixed refund(int $amount, $notes = null) signature has no * (whose fixed refund(int $amount, $notes = null) signature has no
* room for a driver override) — see that extension's own docblock. * room for a driver override) — see that extension's own docblock.