Files

16 KiB

Cart Admin Visibility

Modules\Core\Cart\Filament\Resources\CartResource gives staff read-only visibility into 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: every cart, identified or not

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 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).


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.

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.

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

public static function getNavigationBadge(): ?string
{
    return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->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 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.

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.