From f6ef0761d67f39aa554e9de56d1e8ea9c18e4688 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 28 Aug 2026 13:14:47 +0300 Subject: [PATCH] Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts --- docs/cart.md | 184 ++++++++++++- docs/recovery-strategies.md | 128 +++++++++ src/Cart/Commands/DetectAbandonedCarts.php | 84 ++++++ src/Cart/Events/CartCleared.php | 18 ++ src/Cart/Events/CartCouponApplied.php | 13 + src/Cart/Events/CartCouponRemoved.php | 13 + src/Cart/Events/CartLineAdded.php | 14 + src/Cart/Events/CartLineMovedToCart.php | 18 ++ src/Cart/Events/CartLineRemoved.php | 18 ++ src/Cart/Events/CartLineSaved.php | 20 ++ src/Cart/Events/CartLineUpdated.php | 18 ++ .../Exceptions/InvalidCouponException.php | 19 ++ src/Cart/Filament/Resources/CartResource.php | 12 +- .../CartResource/Pages/ListCarts.php | 29 +- src/Cart/Pipelines/ZeroSavedForLaterPrice.php | 33 +++ src/Cart/Services/CartService.php | 250 ++++++++++++++++++ src/Providers/CartServiceProvider.php | 23 ++ src/Recovery/Events/CartAbandoned.php | 31 +++ src/Recovery/Events/CheckoutAbandoned.php | 33 +++ 19 files changed, 935 insertions(+), 23 deletions(-) create mode 100644 docs/recovery-strategies.md create mode 100644 src/Cart/Commands/DetectAbandonedCarts.php create mode 100644 src/Cart/Events/CartCleared.php create mode 100644 src/Cart/Events/CartCouponApplied.php create mode 100644 src/Cart/Events/CartCouponRemoved.php create mode 100644 src/Cart/Events/CartLineAdded.php create mode 100644 src/Cart/Events/CartLineMovedToCart.php create mode 100644 src/Cart/Events/CartLineRemoved.php create mode 100644 src/Cart/Events/CartLineSaved.php create mode 100644 src/Cart/Events/CartLineUpdated.php create mode 100644 src/Cart/Exceptions/InvalidCouponException.php create mode 100644 src/Cart/Pipelines/ZeroSavedForLaterPrice.php create mode 100644 src/Cart/Services/CartService.php create mode 100644 src/Providers/CartServiceProvider.php create mode 100644 src/Recovery/Events/CartAbandoned.php create mode 100644 src/Recovery/Events/CheckoutAbandoned.php diff --git a/docs/cart.md b/docs/cart.md index 72cef0d..f2d542d 100644 --- a/docs/cart.md +++ b/docs/cart.md @@ -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. diff --git a/docs/recovery-strategies.md b/docs/recovery-strategies.md new file mode 100644 index 0000000..bd1a78f --- /dev/null +++ b/docs/recovery-strategies.md @@ -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. diff --git a/src/Cart/Commands/DetectAbandonedCarts.php b/src/Cart/Commands/DetectAbandonedCarts.php new file mode 100644 index 0000000..80f51aa --- /dev/null +++ b/src/Cart/Commands/DetectAbandonedCarts.php @@ -0,0 +1,84 @@ +whereDoesntHave('orders') + ->where('updated_at', '<=', $cutoff) + ->with('lines') + ->chunkById(200, function ($carts) use (&$cartsAbandoned) { + foreach ($carts as $cart) { + if ($cart->lines->isEmpty()) { + continue; + } + + Event::dispatch(new CartAbandoned($cart)); + + $cartsAbandoned++; + } + }); + + Cart::query() + ->whereHas('orders', fn ($query) => $query->whereNull('placed_at')) + ->where('updated_at', '<=', $cutoff) + ->with(['orders' => fn ($query) => $query->whereNull('placed_at')]) + ->chunkById(200, function ($carts) use (&$checkoutsAbandoned) { + foreach ($carts as $cart) { + $order = $cart->orders->first(); + + if ($order === null) { + continue; + } + + Event::dispatch(new CheckoutAbandoned($cart, $order)); + + $checkoutsAbandoned++; + } + }); + + $this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s)."); + } +} diff --git a/src/Cart/Events/CartCleared.php b/src/Cart/Events/CartCleared.php new file mode 100644 index 0000000..388bba7 --- /dev/null +++ b/src/Cart/Events/CartCleared.php @@ -0,0 +1,18 @@ + $lines + * Snapshot of every line that was in the cart before clearing — Cart::clear() + * deletes all rows directly, so nothing here can be fresh CartLine instances. + */ + public function __construct( + public readonly Cart $cart, + public readonly array $lines, + ) {} +} diff --git a/src/Cart/Events/CartCouponApplied.php b/src/Cart/Events/CartCouponApplied.php new file mode 100644 index 0000000..3a77cd0 --- /dev/null +++ b/src/Cart/Events/CartCouponApplied.php @@ -0,0 +1,13 @@ + Tab::make('Ongoing') ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), - 'abandoned' => Tab::make('Abandoned') - ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '<=', CartResource::abandonedCutoff())), + 'abandoned_cart' => Tab::make('Abandoned Cart') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereDoesntHave('orders') + ->where('updated_at', '<=', CartResource::abandonedCutoff())), + 'abandoned_checkout' => Tab::make('Abandoned Checkout') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at')) + ->where('updated_at', '<=', CartResource::abandonedCutoff())), 'completed' => Tab::make('Completed') ->modifyQueryUsing(fn (Builder $query) => $query->whereHas( 'orders', diff --git a/src/Cart/Pipelines/ZeroSavedForLaterPrice.php b/src/Cart/Pipelines/ZeroSavedForLaterPrice.php new file mode 100644 index 0000000..af5fcb3 --- /dev/null +++ b/src/Cart/Pipelines/ZeroSavedForLaterPrice.php @@ -0,0 +1,33 @@ +meta['saved_for_later'] ?? false) { + $currency = $cartLine->cart->currency; + + $cartLine->unitPrice = new Price(0, $currency, 1); + $cartLine->unitPriceInclTax = new Price(0, $currency, 1); + } + + return $next($cartLine); + } +} diff --git a/src/Cart/Services/CartService.php b/src/Cart/Services/CartService.php new file mode 100644 index 0000000..7d8ddc6 --- /dev/null +++ b/src/Cart/Services/CartService.php @@ -0,0 +1,250 @@ +recalculate() — so a caller gets fresh totals in the same call, + * no second fetch needed. + */ +class CartService +{ + /** + * The current session's cart, or null if none exists yet. Does NOT + * auto-create one — see currentOrCreate() for that. + */ + public function current(): ?Cart + { + return CartSession::current(); + } + + /** + * The current session's cart, creating one if none exists yet — the right + * call for "add to cart" style flows where a cart must exist by the time + * the method returns. + */ + public function currentOrCreate(): Cart + { + return CartSession::manager(); + } + + public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart + { + $cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta); + + $line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class)) + ->execute($cart, $purchasable, $meta); + + if ($line !== null) { + Event::dispatch(new CartLineAdded($cart, $line)); + } + + return $cart; + } + + public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart + { + $before = CartLine::findOrFail($cartLineId); + $old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()]; + + $cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta); + + $line = $cart->lines->firstWhere('id', $cartLineId); + + if ($line !== null) { + Event::dispatch(new CartLineUpdated($cart, $line, $old)); + } + + return $cart; + } + + public function removeLine(int $cartLineId): Cart + { + $line = CartLine::findOrFail($cartLineId); + $snapshot = $this->snapshotLine($line); + + $cart = $this->currentOrCreate()->remove($cartLineId); + + Event::dispatch(new CartLineRemoved($cart, $snapshot)); + + return $cart; + } + + public function clear(): Cart + { + $cart = $this->currentOrCreate(); + $snapshots = $cart->lines->map($this->snapshotLine(...))->all(); + + $cart = $cart->clear(); + + Event::dispatch(new CartCleared($cart, $snapshots)); + + return $cart; + } + + /** + * Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks + * up on the next calculate() — there's no dedicated Lunar action for this + * (unlike add/update/remove, coupon_code is a plain cast attribute), so + * this is the closest thing to one for a consuming app to call. + * + * Validated via Discounts::validateCoupon() (does a matching, currently + * active, non-exhausted Discount exist?) before it's set — 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. + * + * @throws InvalidCouponException if the code doesn't match a valid, active, + * non-exhausted Discount + */ + public function applyCoupon(string $code): Cart + { + if (! Discounts::validateCoupon($code)) { + throw new InvalidCouponException($code); + } + + $cart = $this->currentOrCreate(); + $cart->coupon_code = $code; + $cart->save(); + $cart = $cart->recalculate(); + + Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code)); + + return $cart; + } + + public function removeCoupon(): Cart + { + $cart = $this->currentOrCreate(); + $code = $cart->coupon_code; + + if ($code === null) { + return $cart; + } + + $cart->coupon_code = null; + $cart->save(); + $cart = $cart->recalculate(); + + Event::dispatch(new CartCouponRemoved($cart, $code)); + + return $cart; + } + + /** + * Lines currently counted toward the cart's totals — everything except + * ones flagged meta.saved_for_later (see savedLines()). This is the set a + * cart page's main list / checkout would iterate, since a saved line + * isn't pending purchase. + * + * @return Collection + */ + public function activeLines(?Cart $cart = null): Collection + { + $cart ??= $this->currentOrCreate(); + + return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values(); + } + + /** + * Lines a shopper has deliberately parked rather than deleted — excluded + * from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice) + * and from activeLines(). A cart page's "Saved for later" section iterates + * this set. + * + * @return Collection + */ + public function savedLines(?Cart $cart = null): Collection + { + $cart ??= $this->currentOrCreate(); + + return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values(); + } + + /** + * Moves a line OUT of the purchasable cart without deleting it — it stays + * on the cart (still visible, still re-addable) but is excluded from + * totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before + * Lunar's own CalculateLines sums the cart (which has no meta-based + * exclusion of its own). + */ + public function saveForLater(int $cartLineId): Cart + { + $line = CartLine::findOrFail($cartLineId); + $meta = [...$line->meta->toArray(), 'saved_for_later' => true]; + + $cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta); + + $line = $cart->lines->firstWhere('id', $cartLineId); + + if ($line !== null) { + Event::dispatch(new CartLineSaved($cart, $line)); + } + + return $cart; + } + + /** + * The reverse of saveForLater() — moves a line back into the purchasable + * cart, counted in totals again. + */ + public function moveToCart(int $cartLineId): Cart + { + $line = CartLine::findOrFail($cartLineId); + $meta = [...$line->meta->toArray(), 'saved_for_later' => false]; + + $cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta); + + $line = $cart->lines->firstWhere('id', $cartLineId); + + if ($line !== null) { + Event::dispatch(new CartLineMovedToCart($cart, $line)); + } + + return $cart; + } + + /** + * @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} + */ + private function snapshotLine(CartLine $line): array + { + return [ + 'id' => $line->id, + 'purchasable_type' => $line->purchasable_type, + 'purchasable_id' => $line->purchasable_id, + 'quantity' => $line->quantity, + 'meta' => $line->meta->toArray(), + ]; + } +} diff --git a/src/Providers/CartServiceProvider.php b/src/Providers/CartServiceProvider.php new file mode 100644 index 0000000..81a9ba3 --- /dev/null +++ b/src/Providers/CartServiceProvider.php @@ -0,0 +1,23 @@ +app->runningInConsole()) { + $this->commands([DetectAbandonedCarts::class]); + } + + $this->app->booted(function () { + $this->app->make(Schedule::class) + ->command(DetectAbandonedCarts::class) + ->hourly(); + }); + } +} diff --git a/src/Recovery/Events/CartAbandoned.php b/src/Recovery/Events/CartAbandoned.php new file mode 100644 index 0000000..fa7ae7e --- /dev/null +++ b/src/Recovery/Events/CartAbandoned.php @@ -0,0 +1,31 @@ +