Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views
This commit is contained in:
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user