Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts

This commit is contained in:
2026-08-28 13:14:47 +03:00
parent a8ddbb8056
commit f6ef0761d6
19 changed files with 935 additions and 23 deletions
+173 -11
View File
@@ -26,31 +26,63 @@ equivalent, not a copy of it.
---
## "Abandoned" vs "Completed" — not `Cart::completed_at`
## 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.
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.
The list page's tabs (`ListCarts::getTabs()`) instead key off whether the cart has a
**placed** order:
`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:
- **Abandoned** — mirrors `Cart::scopeActive()` exactly: no orders at all, or only orders
still in draft (`placed_at IS NULL`). Default active tab on page load.
- **Completed** — has at least one order with `placed_at IS NOT NULL`.
- **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
// Abandoned
$query->active();
// 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 ($query) => $query->whereNotNull('placed_at'));
$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 two
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
@@ -108,3 +140,133 @@ The resource is deliberately read-only (`canCreate()` returns `false`, no edit p
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.
+128
View File
@@ -0,0 +1,128 @@
# Cart/Checkout Recovery Strategies — Design Notes
**Status: open design discussion, not scoped or built.** This is a record of the
reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't
have to be re-derived from scratch later. Nothing in this document is implemented.
See `docs/cart.md` for what's actually built today (the four-state cart classification,
`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`).
---
## Why Abandoned Cart and Abandoned Checkout need different strategies
Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent
signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order
exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout
typically captures an email/address even for a guest.
That difference in intent and reachability drives genuinely different marketing strategy, not
just a different admin filter:
### Abandoned Cart strategy — re-engagement, not completion
- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on
return visits) — often the only viable channel, since email may not exist yet.
- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here
specifically because it works off a browser/device signal, not an email address — the one
thing reliably available for an anonymous cart.
- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is
weak, so aggressive discounting is often poor ROI: it trains browsers who were never close
to buying to expect a coupon.
- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out,
sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule.
### Abandoned Checkout strategy — completion, not re-engagement
- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives —
conversion drops sharply with delay, since the shopper is often still in a "was about to
buy" mental state within the first hour.
- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart
contents/total, occasionally a countdown or limited-time incentive on later touches.
- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the
2nd/3rd touch is standard, because it's nudging someone who already decided to buy past
whatever blocked them (price shock, a broken payment step, indecision on shipping cost) —
not manufacturing demand from nothing.
- **SMS is more viable** — checkout often captures a phone number, and the higher intent
justifies a more direct channel than for cart-stage.
---
## The broader strategy space (beyond cadence + discount)
Raised as context for how far a "Recovery Sequence" feature might eventually need to flex,
without committing to building any of it yet:
**Message-content strategies**
- Social proof ("X people have this in their cart," reviews shown in the reminder)
- Scarcity/urgency framing (low-stock count, countdown timer on an offer)
- Personalized alternatives — a cheaper or complementary item instead of just re-showing the
abandoned one, useful when the likely blocker was price
**Channel strategies**
- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section)
- SMS — checkout-stage specifically, opt-in required
- Push notifications — not relevant yet given this project's storefront maturity, noted for
completeness
- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at
all, arguably the highest-value channel for Abandoned Cart specifically
- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting)
**Escalation/segmentation strategies**
- Value-based branching — a high-value abandoned checkout might skip straight to a bigger
incentive rather than waiting through a full ladder
- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing
either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what
stopped you?" survey) instead of another discount
- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant
"welcome discount" framing instead of a generic recovery email, since the blocker was
likely trust/unfamiliarity rather than price
**Timing refinement**
- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay
technically elapsed)
- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more
urgent first touch than a cart of always-in-stock staples
---
## First-pass feature shape (discussed, not finalized)
An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an
ordered sequence of **touches**. Each touch is three ideas:
1. **How long to wait** since the abandonment began
2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the
system rather than inventing a new pricing concept
3. **A label**, so staff can see what a touch represents in the admin UI
The system continuously re-evaluates every abandoned cart/checkout against its sequence, and
when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this
feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is
explicitly out of scope for this feature; something else, not yet designed, would consume that
signal.
### What this requires that isn't built yet
- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a
sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`,
which keeps moving every time the cart (or its own bookkeeping) is written to. This is the
same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section —
fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for
this feature, not a separate concern.
- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart
abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to
check "which touch, if any, is now due," for as long as it stays unrecovered.
### Still undecided
- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) —
candidates raised: a new `Recovery` concern, or `Marketing`. Not decided.
- **How far the touch model needs to flex.** The three-idea shape above (delay, discount,
label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel
choice, value-based branching, or segment targeting from the broader strategy list above.
Whether those get folded into the touch model, layered on top some other way, or deliberately
left out of v1 is unresolved.
- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled
customer touch based on a triggering condition" mechanism that cart/checkout abandonment
happens to be the first use case for.