# 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. --- ## Scope: only carts with a known customer or user `CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')` — an anonymous guest's session cart is excluded entirely. 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. --- ## Four states, not two — and not `Cart::completed_at` `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; 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. `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 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`. ```php // Ongoing $query->active()->where('updated_at', '>', CartResource::abandonedCutoff()); // 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')); ``` 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 (potentially large) table. ### 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. --- ## 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 { return (string) static::getEloquentQuery()->active()->count(); } ``` 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. --- ## `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 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. ### 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.