Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views
This commit is contained in:
@@ -30,6 +30,22 @@ return [
|
||||
|
||||
'cart' => [
|
||||
'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
@@ -1,28 +1,34 @@
|
||||
# Cart Admin Visibility
|
||||
|
||||
`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
|
||||
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
|
||||
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
|
||||
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
|
||||
mechanics this resource reads from.
|
||||
every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
|
||||
admin view at all — no Filament resource for `Cart`/`CartLine` exists anywhere in
|
||||
`lunarphp/lunar` or `lunarphp/core` — this is a from-scratch addition, not an extension of
|
||||
something Lunar half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the
|
||||
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')`
|
||||
— an anonymous guest's session cart is excluded entirely.
|
||||
`CartResource` lists every cart the four lifecycle states (below) cover, with no
|
||||
`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
|
||||
member could act on — no name, no email, nothing to follow up with — so listing every guest
|
||||
session cart would be noise, not a real admin capability. 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`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
|
||||
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
|
||||
checkout step required — so scoping to "identifiable" here is broader than Shopify's
|
||||
equivalent, not a copy of it.
|
||||
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
|
||||
reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
|
||||
email, nothing to follow up with — so listing every guest session cart would be noise, not a
|
||||
real admin capability). That reasoning holds for "can I click through to a Customer record,"
|
||||
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
|
||||
now regardless of who's shopping. Most real storefront traffic never reaches an identified
|
||||
user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
|
||||
`CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
|
||||
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 reachability (checkout usually captures an email even for a guest), so
|
||||
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
|
||||
split:
|
||||
split.
|
||||
|
||||
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
|
||||
active tab on page load.
|
||||
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
|
||||
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
|
||||
- **Completed** — has an order with `placed_at IS NOT NULL`.
|
||||
`Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
|
||||
query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
|
||||
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
|
||||
independently (which is what happened before this service existed, and is exactly the kind of
|
||||
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
|
||||
"abandoned" means):
|
||||
|
||||
```php
|
||||
// Ongoing
|
||||
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
|
||||
- **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
|
||||
`abandonedCutoff()`). Default active tab on page load.
|
||||
- **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
|
||||
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
|
||||
|
||||
// Abandoned Checkout
|
||||
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff());
|
||||
|
||||
// Completed
|
||||
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
|
||||
```
|
||||
Each method takes a `Builder` and returns it further scoped, so callers compose it onto
|
||||
whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
|
||||
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
|
||||
(`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
|
||||
the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
|
||||
a staff member browsing the list.
|
||||
|
||||
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
|
||||
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
|
||||
```php
|
||||
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.
|
||||
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
|
||||
`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
|
||||
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
|
||||
for anything currently stale.
|
||||
builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
|
||||
`ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
|
||||
`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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Modules\Core\Cart\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
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\CheckoutAbandoned;
|
||||
|
||||
@@ -37,7 +37,14 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
|
||||
* that consent is actually recorded), and a non-consenting cart's
|
||||
* 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
|
||||
{
|
||||
@@ -45,33 +52,22 @@ class DetectAbandonedCarts extends Command
|
||||
|
||||
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;
|
||||
$checkoutsAbandoned = 0;
|
||||
|
||||
Cart::query()
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
$lifecycle->abandonedCarts(Cart::query())
|
||||
->where('meta->recovery_consent', true)
|
||||
->with('lines')
|
||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
if ($cart->lines->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CartAbandoned($cart));
|
||||
|
||||
$cartsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
Cart::query()
|
||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
$lifecycle->abandonedCheckouts(Cart::query())
|
||||
->where('meta->recovery_consent', true)
|
||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||
|
||||
@@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||
use Lunar\Models\Cart;
|
||||
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
|
||||
* line, checkout), never hand-edited by staff. Scoped to carts with a known
|
||||
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
|
||||
* identity a staff member could act on (no name, no email, nothing to follow up
|
||||
* with), so listing every such row would be noise, not a real admin capability —
|
||||
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
|
||||
* to follow a precedent from).
|
||||
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
|
||||
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
|
||||
* anonymous cart's Customer/User columns just render "—" (see table() below)
|
||||
* rather than the row being hidden outright: most real traffic never reaches
|
||||
* an identified user/customer, and "how many carts are ongoing/abandoned
|
||||
* 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
|
||||
{
|
||||
@@ -36,12 +38,6 @@ class CartResource extends Resource
|
||||
|
||||
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
|
||||
* 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();
|
||||
}
|
||||
|
||||
public static function lifecycle(): CartLifecycleService
|
||||
{
|
||||
return app(CartLifecycleService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
|
||||
* 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
|
||||
{
|
||||
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
||||
return static::lifecycle()->abandonedCutoff();
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
|
||||
@@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||
|
||||
class ListCarts extends ListRecords
|
||||
{
|
||||
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
|
||||
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
||||
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
||||
*
|
||||
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
|
||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
|
||||
* signal of its own, so recent activity is the only thing distinguishing a
|
||||
* cart someone is shopping in right now from one genuinely left behind.
|
||||
* The four query shapes below live on Modules\Core\Cart\Services\
|
||||
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
|
||||
* DetectAbandonedCarts — see that service's docblock for why duplicating
|
||||
* them independently in both places was worth centralizing.
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
$lifecycle = app(CartLifecycleService::class);
|
||||
|
||||
return [
|
||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
|
||||
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query
|
||||
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
|
||||
'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')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
|
||||
'orders',
|
||||
fn(Builder $query) => $query->whereNotNull('placed_at'),
|
||||
)),
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\ImageEntry;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
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\ProductResource\Pages\EditProduct;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
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
|
||||
* full pipeline for every row of a paginated table would be expensive for no
|
||||
* 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
|
||||
{
|
||||
/** @var Cart $cart */
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -77,6 +94,37 @@ class ViewCart extends ViewRecord
|
||||
RepeatableEntry::make('lines')
|
||||
->hiddenLabel()
|
||||
->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')
|
||||
->label('SKU')
|
||||
->placeholder('—'),
|
||||
@@ -90,6 +138,53 @@ class ViewCart extends ViewRecord
|
||||
])
|
||||
->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')
|
||||
->columns(3)
|
||||
->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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user