2026-08-28 00:27:46 +03:00
|
|
|
# Cart Admin Visibility
|
|
|
|
|
|
|
|
|
|
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
|
2026-09-10 01:13:15 +03:00
|
|
|
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.
|
2026-08-28 00:27:46 +03:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-09-10 01:13:15 +03:00
|
|
|
## Scope: every cart, identified or not
|
2026-08-28 00:27:46 +03:00
|
|
|
|
2026-09-10 01:13:15 +03:00
|
|
|
`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.
|
2026-08-28 00:27:46 +03:00
|
|
|
|
2026-09-10 01:13:15 +03:00
|
|
|
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`).
|
2026-08-28 00:27:46 +03:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-08-28 13:14:47 +03:00
|
|
|
## Four states, not two — and not `Cart::completed_at`
|
2026-08-28 00:27:46 +03:00
|
|
|
|
|
|
|
|
`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but
|
|
|
|
|
**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it;
|
2026-08-28 13:14:47 +03:00
|
|
|
the only hits are the property declaration and the cast. It is not a real signal. `Cart` has
|
|
|
|
|
no `status` column at all — every state below is derived from relations/timestamps, not a
|
|
|
|
|
single field.
|
2026-08-28 00:27:46 +03:00
|
|
|
|
2026-08-28 13:14:47 +03:00
|
|
|
`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two
|
|
|
|
|
distinct states together: no order ever started, vs. a draft order exists
|
|
|
|
|
(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are
|
|
|
|
|
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
|
2026-09-10 01:13:15 +03:00
|
|
|
split.
|
|
|
|
|
|
|
|
|
|
`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):
|
|
|
|
|
|
|
|
|
|
- **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`.
|
|
|
|
|
|
|
|
|
|
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.
|
2026-08-28 00:27:46 +03:00
|
|
|
|
2026-08-28 13:14:47 +03:00
|
|
|
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
|
2026-08-28 00:27:46 +03:00
|
|
|
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
|
|
|
|
|
(potentially large) table.
|
|
|
|
|
|
2026-08-28 13:14:47 +03:00
|
|
|
### Abandoned Cart vs Abandoned Checkout — why they're not one bucket
|
|
|
|
|
|
|
|
|
|
Different purchase intent, different reachability, and different recovery strategy — see
|
|
|
|
|
`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short:
|
|
|
|
|
|
|
|
|
|
- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not
|
|
|
|
|
a near-purchase. Frequently unreachable (no email/identity at all for a true guest).
|
|
|
|
|
Recovery leans on on-site retargeting and ad remarketing rather than email.
|
|
|
|
|
- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper
|
|
|
|
|
committed to buying and something blocked completion. Checkout typically captures contact
|
|
|
|
|
info even for a guest, so this state is usually reachable. This is the state the
|
|
|
|
|
researched 1h/24h/72h recovery-email cadence targets specifically.
|
|
|
|
|
|
|
|
|
|
`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned`
|
|
|
|
|
mirror this same split (see "Events" below) rather than one combined event.
|
|
|
|
|
|
2026-08-28 00:27:46 +03:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Why this scales fine at a large cart count
|
|
|
|
|
|
|
|
|
|
Two things keep this cheap regardless of how many carts exist (10,000+):
|
|
|
|
|
|
|
|
|
|
- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's
|
|
|
|
|
query is active — a page only ever fetches one page's worth of rows, never the whole
|
|
|
|
|
table, "All" tab or not (and there is no "All" tab — see above).
|
|
|
|
|
- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in
|
|
|
|
|
`->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the
|
|
|
|
|
rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no
|
|
|
|
|
per-record `getStateUsing()` closure anywhere in this table doing its own query — that's
|
|
|
|
|
the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\
|
|
|
|
|
Services\ProductIndexer` for the general "compute once at index time / one aggregate
|
|
|
|
|
query, never per-row" principle this project follows elsewhere).
|
|
|
|
|
|
|
|
|
|
The one thing that **does** scan more rows as the cart count grows is
|
|
|
|
|
`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and
|
|
|
|
|
runs once per admin page load, not once per cart row.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Navigation badge — abandoned cart count
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
public static function getNavigationBadge(): ?string
|
|
|
|
|
{
|
2026-09-10 01:13:15 +03:00
|
|
|
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
2026-08-28 00:27:46 +03:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
|
|
|
|
|
staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to
|
|
|
|
|
a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to
|
|
|
|
|
render the badge.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## The view page runs the cart's full calculate pipeline — once
|
|
|
|
|
|
|
|
|
|
`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s
|
|
|
|
|
computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`,
|
|
|
|
|
...) are plain public properties populated as a side effect of that pipeline — never
|
|
|
|
|
persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md`
|
|
|
|
|
Gotchas). This only runs on the single-record view page, not per row in the list table —
|
|
|
|
|
running the full 5-step pipeline for every row of a paginated list would be needless cost for
|
|
|
|
|
data the list doesn't display.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Not built: staff editing a cart
|
|
|
|
|
|
|
|
|
|
The resource is deliberately read-only (`canCreate()` returns `false`, no edit page
|
|
|
|
|
registered). A cart is owned by the storefront's own add/update/remove flow
|
|
|
|
|
(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a
|
|
|
|
|
supported use case here.
|
2026-08-28 13:14:47 +03:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## `CartService` — the storefront-facing API
|
|
|
|
|
|
|
|
|
|
`Modules\Core\Cart\Services\CartService` mirrors `Modules\Core\Catalog\Services\
|
|
|
|
|
ProductService`/`CollectionService`'s shape — one boboko-owned API a storefront calls, so
|
|
|
|
|
Lunar's own `CartSession`/`Cart` stay an implementation detail rather than something a
|
|
|
|
|
consuming app depends on directly.
|
|
|
|
|
|
|
|
|
|
- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`),
|
|
|
|
|
the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see
|
|
|
|
|
`docs/lunar.md`'s Cart gotchas).
|
|
|
|
|
- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over
|
|
|
|
|
`Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap
|
|
|
|
|
Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`,
|
|
|
|
|
etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.
|
|
|
|
|
- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated
|
|
|
|
|
Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via
|
|
|
|
|
`Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\
|
|
|
|
|
InvalidCouponException` on a bad code — `CouponString`'s cast only normalizes casing, it
|
|
|
|
|
doesn't validate anything, so setting `coupon_code` directly would silently accept a bogus
|
|
|
|
|
code and just not discount anything once calculated.
|
|
|
|
|
- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later"
|
|
|
|
|
below.
|
|
|
|
|
|
|
|
|
|
Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()`
|
|
|
|
|
etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a
|
|
|
|
|
matching domain event.
|
|
|
|
|
|
|
|
|
|
### Events — Lunar dispatches none of its own
|
|
|
|
|
|
|
|
|
|
`Lunar` dispatches zero cart events — no "item added," no "cart created" (see
|
|
|
|
|
`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after
|
|
|
|
|
the underlying Lunar operation completes:
|
|
|
|
|
|
|
|
|
|
`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`,
|
|
|
|
|
`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under
|
|
|
|
|
`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under
|
|
|
|
|
`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection"
|
|
|
|
|
below for why.
|
|
|
|
|
|
|
|
|
|
**None of these currently have a listener.** They're dispatched-but-unconsumed by design —
|
|
|
|
|
built so something downstream (reindexing, notifications, a future read-side reporting
|
|
|
|
|
service) has a hook to attach to, not because a concrete consumer exists today. This was a
|
|
|
|
|
deliberate decision, not an oversight — see the "don't build speculative infrastructure"
|
|
|
|
|
calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
|
|
|
|
|
|
|
|
|
|
**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own
|
|
|
|
|
`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
|
|
|
|
|
model saves/deletes automatically, independent of actor. `Modules\Core\Logging\
|
|
|
|
|
ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is
|
|
|
|
|
hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin
|
|
|
|
|
actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to
|
|
|
|
|
`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity`
|
|
|
|
|
write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in
|
|
|
|
|
separation beyond reading `causer_type` per row — a real limitation worth knowing about, but
|
|
|
|
|
not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar
|
|
|
|
|
model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The
|
|
|
|
|
intended fix, if this becomes a real need, is a read-side service that queries `activity_log`
|
|
|
|
|
and classifies by `causer_type`/`log_name` — not touching every write site.
|
|
|
|
|
|
|
|
|
|
### Save for later
|
|
|
|
|
|
|
|
|
|
A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via
|
|
|
|
|
`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used
|
|
|
|
|
elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\
|
|
|
|
|
ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the
|
|
|
|
|
stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before**
|
|
|
|
|
Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every
|
|
|
|
|
`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price
|
|
|
|
|
upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or
|
|
|
|
|
callers needing a different totals accessor.
|
|
|
|
|
|
|
|
|
|
`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain
|
|
|
|
|
`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's
|
|
|
|
|
existing meta and merge in the flag change before calling `Cart::updateLine()`, or an
|
|
|
|
|
unrelated meta key set by something else would be silently wiped.
|
|
|
|
|
|
|
|
|
|
### Coupons
|
|
|
|
|
|
|
|
|
|
See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString`
|
|
|
|
|
just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the
|
|
|
|
|
`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or
|
|
|
|
|
`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Abandonment detection
|
|
|
|
|
|
|
|
|
|
"Abandoned" is a **derived** state (`Cart::updated_at` older than
|
|
|
|
|
`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it
|
|
|
|
|
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
|
2026-09-10 01:13:15 +03:00
|
|
|
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`.
|
2026-08-28 13:14:47 +03:00
|
|
|
|
|
|
|
|
### Cart/Checkout have zero abandonment-related writes — by design
|
|
|
|
|
|
|
|
|
|
`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An
|
|
|
|
|
earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid
|
|
|
|
|
refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an
|
|
|
|
|
Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed
|
|
|
|
|
from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a
|
|
|
|
|
cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart,"
|
|
|
|
|
on the very next tab-count check.
|
|
|
|
|
|
|
|
|
|
The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from
|
|
|
|
|
having any way to write abandonment state at all. Deduplication ("has this cart already been
|
|
|
|
|
notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet
|
|
|
|
|
built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping
|
|
|
|
|
`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys.
|
|
|
|
|
|
|
|
|
|
**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still
|
|
|
|
|
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
|
|
|
|
|
all right now. That's fine today only because nothing consumes these events yet (see
|
|
|
|
|
"Events" above); it would need addressing before anything real listens for them.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Recovery Sequences — design only, not built
|
|
|
|
|
|
|
|
|
|
See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass
|
|
|
|
|
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
|
|
|
|
|
label) per abandonment type. Explicitly parked as an open design question, not scoped for
|
|
|
|
|
implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern,
|
|
|
|
|
and how far the touch model needs to flex (channel choice, value-based branching, segment
|
|
|
|
|
targeting) are all still undecided.
|