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.