Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views

This commit is contained in:
2026-09-10 01:13:15 +03:00
parent 8f4c1a22ea
commit 864c8b19aa
7 changed files with 296 additions and 86 deletions
+16
View File
@@ -30,6 +30,22 @@ return [
'cart' => [ 'cart' => [
'abandoned_after' => '1 hour', 'abandoned_after' => '1 hour',
/*
|----------------------------------------------------------------------
| Unrecoverable Cap
|----------------------------------------------------------------------
|
| Beyond this age, a stale cart stops being treated as an active
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
| CartLifecycleService) — too old to be a realistic recovery target
| (pricing/stock/tax likely stale by then). This is about the
| abandoned-cart pipeline only, not data retention — no rows are
| deleted or pruned based on this value.
|
*/
'unrecoverable_after' => '90 days',
], ],
]; ];
+48 -40
View File
@@ -1,28 +1,34 @@
# Cart Admin Visibility # Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or admin view at all — no Filament resource for `Cart`/`CartLine` exists anywhere in
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar `lunarphp/lunar` or `lunarphp/core` — this is a from-scratch addition, not an extension of
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart something Lunar half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the
mechanics this resource reads from. underlying Lunar cart mechanics this resource reads from.
--- ---
## Scope: only carts with a known customer or user ## Scope: every cart, identified or not
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')` `CartResource` lists every cart the four lifecycle states (below) cover, with no
— an anonymous guest's session cart is excluded entirely. `user_id`/`customer_id` filter — an anonymous guest's session cart is included.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
member could act on — no name, no email, nothing to follow up with — so listing every guest reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's email, nothing to follow up with — so listing every guest session cart would be noise, not a
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a real admin capability). That reasoning holds for "can I click through to a Customer record,"
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a now regardless of who's shopping. Most real storefront traffic never reaches an identified
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
checkout step required — so scoping to "identifiable" here is broader than Shopify's `CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
equivalent, not a copy of it. on. The `Customer`/`User` columns on a guest row just render "—" (Filament's `placeholder()`)
instead of a link — nothing to click into, but the row and its contents are still visible via
`ViewCart`.
This does **not** mirror Shopify's admin (Shopify has no "all carts" view at all — only
"Abandoned checkouts," gated on a shopper reaching checkout and entering contact info, a
later/narrower stage than Lunar's `Cart`).
--- ---
@@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state `ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split: split.
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default `Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
active tab on page load. query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`. (abandonment-event dispatch) build on it, rather than each reimplementing the same split
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`. independently (which is what happened before this service existed, and is exactly the kind of
- **Completed** — has an order with `placed_at IS NOT NULL`. drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
"abandoned" means):
```php - **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
// Ongoing `abandonedCutoff()`). Default active tab on page load.
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff()); - **Abandoned Cart** (`abandonedCarts()`) — `whereDoesntHave('orders')` and stale
`updated_at`.
- **Abandoned Checkout** (`abandonedCheckouts()`) — has an order with `placed_at IS NULL`,
and stale `updated_at`.
- **Completed** (`completed()`) — has an order with `placed_at IS NOT NULL`.
// Abandoned Cart Each method takes a `Builder` and returns it further scoped, so callers compose it onto
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff()); whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
// Abandoned Checkout (`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at')) the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
->where('updated_at', '<=', CartResource::abandonedCutoff()); a staff member browsing the list.
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
```php ```php
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
return (string) static::getEloquentQuery()->active()->count(); return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
} }
``` ```
@@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v
via a normal Eloquent write, so there's no model-event hook to dispatch from directly. via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by `Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it `Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` `ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
for anything currently stale. `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for anything currently stale
that also has `meta->recovery_consent = true`.
### Cart/Checkout have zero abandonment-related writes — by design ### Cart/Checkout have zero abandonment-related writes — by design
+12 -16
View File
@@ -5,7 +5,7 @@ namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Cart\Services\CartLifecycleService;
use Modules\Core\Recovery\Events\CartAbandoned; use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned; use Modules\Core\Recovery\Events\CheckoutAbandoned;
@@ -37,7 +37,14 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where * sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's * that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later * abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning. * at send time — see docs referenced above for the legal reasoning. This
* consent filter stays here rather than on Modules\Core\Cart\Services\
* CartLifecycleService, whose two "abandoned" queries this command builds
* on — dispatch eligibility is this command's own concern, not part of
* what "abandoned" means to a staff member browsing the admin panel. (The
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
* means either way, so it lives on CartLifecycleService::abandonedCarts()
* itself, not here.)
*/ */
class DetectAbandonedCarts extends Command class DetectAbandonedCarts extends Command
{ {
@@ -45,33 +52,22 @@ class DetectAbandonedCarts extends Command
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.'; protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void public function handle(CartLifecycleService $lifecycle): void
{ {
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0; $cartsAbandoned = 0;
$checkoutsAbandoned = 0; $checkoutsAbandoned = 0;
Cart::query() $lifecycle->abandonedCarts(Cart::query())
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->where('meta->recovery_consent', true) ->where('meta->recovery_consent', true)
->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) { ->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) { foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart)); Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++; $cartsAbandoned++;
} }
}); });
Cart::query() $lifecycle->abandonedCheckouts(Cart::query())
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
->where('meta->recovery_consent', true) ->where('meta->recovery_consent', true)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')]) ->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) { ->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
+15 -14
View File
@@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages; use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Modules\Core\Cart\Services\CartLifecycleService;
/** /**
* Read-only — a cart is managed entirely through the storefront (add/update/remove * Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known * line, checkout), never hand-edited by staff. Lists every cart, guest carts
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no * included — see docs/cart.md ("Scope: every cart, identified or not"). An
* identity a staff member could act on (no name, no email, nothing to follow up * anonymous cart's Customer/User columns just render "—" (see table() below)
* with), so listing every such row would be noise, not a real admin capability — * rather than the row being hidden outright: most real traffic never reaches
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all * an identified user/customer, and "how many carts are ongoing/abandoned
* to follow a precedent from). * right now" is a real reporting need regardless of identity — excluding
* anonymous carts would silently undercount it. Lunar itself ships no cart
* admin view at all to follow a precedent from.
*/ */
class CartResource extends Resource class CartResource extends Resource
{ {
@@ -36,12 +38,6 @@ class CartResource extends Resource
protected static ?string $pluralModelLabel = 'Carts'; protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/** /**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned * Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order, * states (`active()` already covers "no order at all" and "draft order,
@@ -56,6 +52,11 @@ class CartResource extends Resource
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count(); return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
} }
public static function lifecycle(): CartLifecycleService
{
return app(CartLifecycleService::class);
}
/** /**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very * `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now, * different things together: a cart someone is actively shopping in right now,
@@ -67,7 +68,7 @@ class CartResource extends Resource
*/ */
public static function abandonedCutoff(): Carbon public static function abandonedCutoff(): Carbon
{ {
return now()->sub(config('core.cart.abandoned_after', '1 hour')); return static::lifecycle()->abandonedCutoff();
} }
public static function table(Table $table): Table public static function table(Table $table): Table
@@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
class ListCarts extends ListRecords class ListCarts extends ListRecords
{ {
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned / * bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw. * Modules\Core\Recovery\Events\CheckoutAbandoned draw.
* *
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against * The four query shapes below live on Modules\Core\Cart\Services\
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness * CartLifecycleService, shared with Modules\Core\Cart\Commands\
* signal of its own, so recent activity is the only thing distinguishing a * DetectAbandonedCarts — see that service's docblock for why duplicating
* cart someone is shopping in right now from one genuinely left behind. * them independently in both places was worth centralizing.
*/ */
public function getTabs(): array public function getTabs(): array
{ {
$lifecycle = app(CartLifecycleService::class);
return [ return [
'abandoned_cart' => Tab::make('Abandoned Cart') 'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn(Builder $query) => $query ->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'abandoned_checkout' => Tab::make('Abandoned Checkout') 'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn(Builder $query) => $query ->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'ongoing' => Tab::make('Ongoing') 'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), ->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)),
'completed' => Tab::make('Completed') 'completed' => Tab::make('Completed')
->modifyQueryUsing(fn(Builder $query) => $query->whereHas( ->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
'orders',
fn(Builder $query) => $query->whereNotNull('placed_at'),
)),
]; ];
} }
} }
@@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Colors\Color;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\CartLine; use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord class ViewCart extends ViewRecord
@@ -35,12 +41,23 @@ class ViewCart extends ViewRecord
* (a single view page load), not per-row in the list table, since running the * (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no * full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas. * real benefit — see docs/lunar.md's Cart gotchas.
*
* Eager-loads what the Lines section (below) reads off each line's
* purchasable — name, thumbnail, options — the same relations Lunar's
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line.
*/ */
protected function resolveRecord(int|string $key): Cart protected function resolveRecord(int|string $key): Cart
{ {
/** @var Cart $cart */ /** @var Cart $cart */
$cart = parent::resolveRecord($key); $cart = parent::resolveRecord($key);
$cart->load('lines.purchasable', 'shippingAddress.country');
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']);
return $cart->calculate(); return $cart->calculate();
} }
@@ -77,6 +94,37 @@ class ViewCart extends ViewRecord
RepeatableEntry::make('lines') RepeatableEntry::make('lines')
->hiddenLabel() ->hiddenLabel()
->schema([ ->schema([
ImageEntry::make('image')
->hiddenLabel()
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? $record->purchasable->getThumbnail()?->getUrl('small')
: null)
->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode(
Blade::render('<x-filament::icon icon="heroicon-o-photo" style="color:rgb('.Color::Gray[400].');"/>')
))
->imageSize(48),
TextEntry::make('description')
->label('Product')
// ProductVariant::getDescription()/getOption() are typed
// string but internally read translateAttribute()/
// translate(), which return null for a product/option
// with no attribute data set for the active locale —
// reading the underlying relations directly here avoids
// that TypeError rather than calling through them.
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->product?->translateAttribute('name') ?? '—')
: '—')
->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? EditProduct::getUrl(['record' => $record->purchasable->product_id])
: null)
->weight('bold'),
TextEntry::make('options')
->label('Options')
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null)
: null)
->placeholder('—')
->badge(),
TextEntry::make('purchasable.sku') TextEntry::make('purchasable.sku')
->label('SKU') ->label('SKU')
->placeholder('—'), ->placeholder('—'),
@@ -90,6 +138,53 @@ class ViewCart extends ViewRecord
]) ])
->columns(4), ->columns(4),
]), ]),
Section::make('Shipping')
->columns(3)
->schema([
TextEntry::make('shippingAddress.shipping_option')
->label('Shipping method')
// The raw identifier (e.g. "acs") is all a
// CartAddress row stores — the human-readable
// name only exists on the resolved
// Lunar\DataTypes\ShippingOption, which is what
// shippingBreakdown's items are keyed/named
// from below, so fall back to that name rather
// than showing the bare identifier.
->formatStateUsing(fn (Cart $record, ?string $state) => $state
? ($record->shippingBreakdown?->items->get($state)?->name ?? $state)
: null)
->placeholder('Not selected'),
TextEntry::make('shippingAddress.country.name')
->label('Shipping to')
->placeholder('—'),
TextEntry::make('shippingTotal')
->label('Shipping total')
->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—')
->weight('bold'),
RepeatableEntry::make('shippingBreakdownItems')
->label('Breakdown')
->columnSpanFull()
// shippingBreakdown->items is a plain (non-Eloquent)
// Collection of Lunar\Base\ValueObjects\Cart\
// ShippingBreakdownItem — e.g. the carrier rate and,
// separately, Modules\Core\Payment\Pipelines\Cart\
// ApplyPaymentMethodFee's own line item when the
// selected payment method carries a fee (see
// CHANGELOG 0.16.3) — both show up here individually
// rather than only as the summed shippingTotal above.
->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? [])
->schema([
TextEntry::make('name')
->hiddenLabel(),
TextEntry::make('price')
->hiddenLabel()
->formatStateUsing(fn ($state) => $state?->formatted() ?? '—')
->alignEnd(),
])
->columns(2)
->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()),
])
->visible(fn (Cart $record) => $record->shippingAddress !== null),
Section::make('Totals') Section::make('Totals')
->columns(3) ->columns(3)
->schema([ ->schema([
@@ -0,0 +1,99 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Models\Cart;
/**
* The single source of truth for the four cart lifecycle states documented in
* docs/cart.md ("Four states, not two — and not Cart::completed_at"). Both
* Modules\Core\Cart\Filament\Resources\CartResource/ListCarts (staff-facing
* browsing/tabs) and Modules\Core\Cart\Commands\DetectAbandonedCarts
* (abandonment-event dispatch) build on these same four query shapes — before
* this existed, each reimplemented them independently, which is exactly the
* kind of drift that lets the admin panel and the recovery-email pipeline
* quietly disagree about what "abandoned" means.
*
* `Cart::completed_at` is declared/cast on the model but never actually
* written anywhere in Lunar core — not a real signal, not used here.
* `Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope)
* mixes two distinct states together (no order at all vs. a draft order that
* was never placed) — see docs/cart.md for why they're kept apart as
* different purchase-intent/reachability signals rather than folded into one
* "not converted" bucket.
*
* Query shape only: consent (`meta->recovery_consent`) and non-empty-lines
* filtering stay in DetectAbandonedCarts, not here — those are specific to
* whether a recovery event should fire, not to what "abandoned" means. Staff
* browsing the admin panel should see every abandoned cart, consenting or
* not.
*
* `unrecoverableCutoff()` is a second, older threshold
* (`core.cart.unrecoverable_after`, default 90 days) applied as a lower
* bound on both abandoned*() methods below: a cart past it is too old to be
* a realistic recovery target (pricing/stock/tax have likely moved on), so
* it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than
* staying flagged as an actionable abandonment forever. It does not appear
* in `ongoing()`/`completed()` either — this is about the abandoned-cart
* pipeline specifically, not a retention/deletion policy (no rows are
* touched here).
*/
class CartLifecycleService
{
public function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public function unrecoverableCutoff(): Carbon
{
return now()->sub(config('core.cart.unrecoverable_after', '90 days'));
}
/**
* Not yet converted to an order (scopeActive()), with recent activity —
* someone plausibly shopping right now, not (yet) left behind.
*/
public function ongoing(Builder $query): Builder
{
return $query->active()->where('updated_at', '>', $this->abandonedCutoff());
}
/**
* No order started at all, stale, not yet past the unrecoverable cap, and
* actually has something in it — the weaker of the two abandoned states
* (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty
* cart (created but nothing ever added — e.g. a bot, or a session that
* never shopped) was never really "abandoned"; there's nothing to
* recover, so it's excluded rather than counted as a false positive.
*/
public function abandonedCarts(Builder $query): Builder
{
return $query->whereDoesntHave('orders')
->whereHas('lines')
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* A draft order exists (checkout was started) but was never placed,
* stale, and not yet past the unrecoverable cap — the stronger of the
* two abandoned states.
*/
public function abandonedCheckouts(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* Has an order that was actually placed, not just drafted.
*/
public function completed(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at'));
}
}