Compare commits

...
5 Commits
10 changed files with 179 additions and 56 deletions
+46
View File
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.17.2] - 2026-09-15
### Fixed
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past
`awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order
could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet
captured funds), `status` advances to the next step in the order's flow
(`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still
exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff
already moved further.
- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's
`Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an
entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\
PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own
payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\
Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now
fixes both the refund and capture header actions — see below) now routes capture through
`Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path
checkout-time captures use.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four
digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always
empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered
with no digits — reproduced on both checkout-time and manual captures. Added
`cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge`
(same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta`
from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder`
now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to
transactions recorded after this change — existing rows are not backfilled.
### Changed
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the
order page, not just refund, so the old name undersold its scope.
## [0.17.1] - 2026-09-15
### Fixed
- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set
`automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always
sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the
Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods`
unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts
itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side,
which drops the `return_url` requirement.
## [0.17.0] - 2026-09-14 ## [0.17.0] - 2026-09-14
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.17.0", "version": "0.17.2",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+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
@@ -49,6 +49,8 @@ class TransactionRecorder
'reference' => $result->reference, 'reference' => $result->reference,
'status' => $result->status->name, 'status' => $result->status->name,
'notes' => $result->failureReason, 'notes' => $result->failureReason,
'card_type' => $result->meta['card_type'] ?? null,
'last_four' => $result->meta['last_four'] ?? null,
'meta' => $result->meta, 'meta' => $result->meta,
]); ]);
} }
@@ -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
+44 -3
View File
@@ -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
@@ -100,12 +99,18 @@ class StripePaymentDriver implements
'currency' => $amount->currency->code, 'currency' => $amount->currency->code,
'capture_method' => $captureMethod, 'capture_method' => $captureMethod,
'confirm' => true, 'confirm' => true,
// 'never' rather than the client-side paymentMethodTypes: ['card']
// restriction alone — the storefront's Payment Element already
// excludes every redirect-based method, but without this Stripe
// still falls back to whatever's enabled in the Dashboard and
// demands a return_url on confirm. Setting this unconditionally
// (not only when no payment_method is given) matches the actual
// flow: a payment_method is always supplied here.
'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'],
]; ];
if (isset($data['payment_method'])) { if (isset($data['payment_method'])) {
$params['payment_method'] = $data['payment_method']; $params['payment_method'] = $data['payment_method'];
} else {
$params['automatic_payment_methods'] = ['enabled' => true];
} }
try { try {
@@ -165,6 +170,7 @@ class StripePaymentDriver implements
reference: $paymentIntent->id, reference: $paymentIntent->id,
amount: $amount, amount: $amount,
raw: $paymentIntent->toArray(), raw: $paymentIntent->toArray(),
meta: $this->cardMetaFromIntent($paymentIntent),
); );
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
@@ -335,6 +341,7 @@ class StripePaymentDriver implements
amount: $amount, amount: $amount,
failureReason: $paymentIntent->last_payment_error->message ?? null, failureReason: $paymentIntent->last_payment_error->message ?? null,
raw: $paymentIntent->toArray(), raw: $paymentIntent->toArray(),
meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent),
continuation: $continuation, continuation: $continuation,
); );
@@ -357,6 +364,40 @@ class StripePaymentDriver implements
return $result; return $result;
} }
/**
* card_type/last_four for Modules\Core\Order\Services\
* TransactionRecorder to map onto Transaction (see PaymentResult::
* $meta's own docblock) — same fields, same source
* (payment_method_details on the underlying Charge) as lunarphp/
* stripe's own StoreCharges, just reached via latest_charge instead of
* an order-level charge list, since this driver has no Order/Cart to
* enumerate charges from.
*
* @return array{card_type?: string, last_four?: string}
*/
private function cardMetaFromIntent(PaymentIntent $paymentIntent): array
{
$chargeId = $paymentIntent->latest_charge;
if (blank($chargeId)) {
return [];
}
$charge = Stripe::getCharge(is_string($chargeId) ? $chargeId : $chargeId->id);
$paymentType = collect($charge->payment_method_details)->keys()->first();
$details = collect($charge->payment_method_details)->first();
if (blank($details)) {
return [];
}
return array_filter([
'card_type' => $details['brand'] ?? $paymentType,
'last_four' => $details['last4'] ?? null,
], fn ($value) => filled($value));
}
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
{ {
$result = $this->failure($amount, $e); $result = $this->failure($amount, $e);
@@ -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.