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
@@ -0,0 +1,84 @@
<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
/**
* "Abandoned" is a derived state (Cart::updated_at older than
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
* via a normal Eloquent write, so there's no model-event hook to dispatch
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
* place that moment gets detected; run it on a schedule (see docs/cart.md).
*
* Splits Cart::scopeActive()'s two branches into their own events —
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
* not one combined "abandoned" state: a cart with no order at all is a much
* weaker purchase-intent signal than one with a draft order that was never
* placed.
*
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
* earlier version recorded an "already notified" marker on Cart::meta/
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
* which un-staled the very cart being marked abandoned (the same field
* abandonment staleness is computed from) — see docs/cart.md's former
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*/
class DetectAbandonedCarts extends Command
{
protected $signature = 'boboko:cart:detect-abandoned';
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->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).");
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCleared
{
/**
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $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,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponApplied
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponRemoved
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineAdded
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* The reverse of CartLineSaved — a previously saved-for-later line moved back
* into the purchasable cart (now counted in totals again).
*/
class CartLineMovedToCart
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartLineRemoved
{
/**
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
* Snapshot of the removed line — the row is already deleted by the time this
* event dispatches, so nothing here can be a fresh CartLine model instance.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $line,
) {}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* A line was moved OUT of the purchasable cart and into "saved for later" —
* not a removal (the row still exists), but distinct from CartLineUpdated
* since it's a state transition worth its own hook (e.g. abandoned-cart
* recovery treating a saved line very differently from a deleted one).
*/
class CartLineSaved
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineUpdated
{
/**
* @param array{quantity: int, meta: array} $old Snapshot before the update.
*/
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
public readonly array $old,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Cart\Exceptions;
use RuntimeException;
/**
* Thrown by CartService::applyCoupon() when the given code doesn't match any
* currently-active, non-exhausted Discount — Lunar's own
* Discounts::validateCoupon() only returns a bool, it has no matching
* exception type of its own to reuse here.
*/
class InvalidCouponException extends RuntimeException
{
public function __construct(public readonly string $code)
{
parent::__construct("The coupon code \"{$code}\" is not valid.");
}
}
+7 -5
View File
@@ -39,11 +39,13 @@ class CartResource extends Resource
}
/**
* Count only, not a fetch — no rows are loaded. Scoped to genuinely abandoned
* carts specifically (mirrors ListCarts::getTabs()'s "Abandoned" query, not
* "Ongoing"), since that's the number a staff member glancing at the sidebar
* actually wants: how many carts might need following up on, not the total
* including ones someone is actively shopping in right now.
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
* — the badge is meant to answer "how many carts might need following up
* on," not the total including ones someone is actively shopping in right
* now.
*/
public static function getNavigationBadge(): ?string
{
@@ -17,19 +17,34 @@ class ListCarts extends ListRecords
* "Completed" instead means the cart has an order with `placed_at` set (a
* placed, not just drafted, order).
*
* "Ongoing" vs "Abandoned" both start from `Cart::scopeActive()` (not yet
* converted to an order) and split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness signal
* of its own, so recent activity is the only thing distinguishing a cart
* someone is shopping in right now from one genuinely left behind.
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
* distinct states: no order started at all, vs. a draft order exists
* (`placed_at IS NULL`) but was never placed — checkout was started, not
* finished. That's a real difference in purchase intent (a cart with a
* draft order is a much stronger signal than one with no order at all) and
* in reachability (checkout usually captures an email even for a guest),
* so they get separate tabs rather than one combined "no order yet"
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
*/
public function getTabs(): array
{
return [
'ongoing' => 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',
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Cart\Pipelines;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\CartLine as CartLineContract;
/**
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
* zeroes out unitPrice/unitPriceInclTax for any line flagged
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
* since CalculateLines sums every CartLine unconditionally with no meta-based
* exclusion of its own, zeroing the price here (rather than patching subTotal
* after the fact) is what makes every downstream total naturally correct
* without a second pass.
*/
class ZeroSavedForLaterPrice
{
public function handle(CartLineContract $cartLine, Closure $next): mixed
{
if ($cartLine->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);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Actions\Carts\GetExistingCartLine;
use Lunar\Base\Purchasable;
use Lunar\Facades\CartSession;
use Lunar\Facades\Discounts;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartCouponApplied;
use Modules\Core\Cart\Events\CartCouponRemoved;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineMovedToCart;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineSaved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
/**
* Storefront-facing cart operations, mirroring 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.
*
* Every mutating method dispatches a matching domain event
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
* so without this, nothing in a consuming app has anything to react to when a
* cart actually changes (reindexing, notifications, analytics, etc.).
*
* All mutating methods return the recalculated Cart — matching Lunar's own
* Cart::add()/updateLine()/etc., which already return $this after
* refresh()->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<int, CartLine>
*/
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<int, CartLine>
*/
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(),
];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([DetectAbandonedCarts::class]);
}
$this->app->booted(function () {
$this->app->make(Schedule::class)
->command(DetectAbandonedCarts::class)
->hourly();
});
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
/**
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
* with NO order ever started — the shopper added items and never began
* checkout. Weak purchase-intent signal: usually a browsing/price-check
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
* for a cart that DID reach checkout (a draft Order exists) but never placed
* it — a much stronger intent signal, and reachable via the email/address
* checkout itself usually captures even for a guest.
*
* Lives under Recovery, not Cart — abandonment detection/tracking is
* deliberately kept out of the Cart module entirely, including its event
* definitions, so Cart has no abandonment-related code at all. See
* docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at), not something that
* transitions via a normal Eloquent write, so there's no natural model-event
* hook to dispatch this from directly — detection is Recovery's own concern
* (not yet built; design notes in docs/recovery-strategies.md).
*/
class CartAbandoned
{
public function __construct(
public readonly Cart $cart,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A cart's checkout has gone stale (no activity for
* config('core.cart.abandoned_after')) with a draft Order already created
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
* purchase-intent signal — the shopper committed to checking out, something
* blocked completion. Distinct from CartAbandoned, which fires for a cart
* with no order at all (weak intent, usually unreachable). Checkout
* typically captures an email/address even for a guest, so this state is
* normally reachable regardless of login status.
*
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
* is deliberately kept out of both modules entirely, including its event
* definitions. See docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
* something that transitions via a normal Eloquent write — detection is
* Recovery's own concern (not yet built; design notes in
* docs/recovery-strategies.md).
*/
class CheckoutAbandoned
{
public function __construct(
public readonly Cart $cart,
public readonly Order $order,
) {}
}