From 864c8b19aa31c63f1c5e4e86f1e30fc888d14970 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 10 Sep 2026 01:13:15 +0300 Subject: [PATCH] Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views --- config/core.php | 16 +++ docs/cart.md | 88 +++++++++-------- src/Cart/Commands/DetectAbandonedCarts.php | 28 +++--- src/Cart/Filament/Resources/CartResource.php | 29 +++--- .../CartResource/Pages/ListCarts.php | 27 +++-- .../Resources/CartResource/Pages/ViewCart.php | 95 ++++++++++++++++++ src/Cart/Services/CartLifecycleService.php | 99 +++++++++++++++++++ 7 files changed, 296 insertions(+), 86 deletions(-) create mode 100644 src/Cart/Services/CartLifecycleService.php diff --git a/config/core.php b/config/core.php index aad0445..4f09573 100644 --- a/config/core.php +++ b/config/core.php @@ -30,6 +30,22 @@ return [ 'cart' => [ 'abandoned_after' => '1 hour', + + /* + |---------------------------------------------------------------------- + | Unrecoverable Cap + |---------------------------------------------------------------------- + | + | Beyond this age, a stale cart stops being treated as an active + | "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\ + | CartLifecycleService) — too old to be a realistic recovery target + | (pricing/stock/tax likely stale by then). This is about the + | abandoned-cart pipeline only, not data retention — no rows are + | deleted or pruned based on this value. + | + */ + + 'unrecoverable_after' => '90 days', ], ]; diff --git a/docs/cart.md b/docs/cart.md index f2d542d..0e99efa 100644 --- a/docs/cart.md +++ b/docs/cart.md @@ -1,28 +1,34 @@ # Cart Admin Visibility `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into -customer/user carts in the Filament admin panel. 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. +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: only carts with a known customer or user +## Scope: every cart, identified or not -`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')` -— an anonymous guest's session cart is excluded entirely. +`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 deliberate call, not an oversight: 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. 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`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a -shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no -checkout step required — so scoping to "identifiable" here is broader than Shopify's -equivalent, not a copy of it. +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`). --- @@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists 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: +split. -- **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`. +`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): -```php -// Ongoing -$query->active()->where('updated_at', '>', CartResource::abandonedCutoff()); +- **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`. -// 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 ($q) => $q->whereNotNull('placed_at')); -``` +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 @@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row. ```php public static function getNavigationBadge(): ?string { - return (string) static::getEloquentQuery()->active()->count(); + return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count(); } ``` @@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v 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. +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`. ### Cart/Checkout have zero abandonment-related writes — by design diff --git a/src/Cart/Commands/DetectAbandonedCarts.php b/src/Cart/Commands/DetectAbandonedCarts.php index 4663607..3169c86 100644 --- a/src/Cart/Commands/DetectAbandonedCarts.php +++ b/src/Cart/Commands/DetectAbandonedCarts.php @@ -5,7 +5,7 @@ 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\Cart\Services\CartLifecycleService; use Modules\Core\Recovery\Events\CartAbandoned; use Modules\Core\Recovery\Events\CheckoutAbandoned; @@ -37,7 +37,14 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned; * sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where * that consent is actually recorded), and a non-consenting cart's * abandonment must never be dispatched at all, not merely filtered later - * at send time — see docs referenced above for the legal reasoning. + * at send time — see docs referenced above for the legal reasoning. This + * consent filter stays here rather than on Modules\Core\Cart\Services\ + * CartLifecycleService, whose two "abandoned" queries this command builds + * on — dispatch eligibility is this command's own concern, not part of + * what "abandoned" means to a staff member browsing the admin panel. (The + * non-empty-lines requirement, by contrast, IS part of what "abandoned" + * means either way, so it lives on CartLifecycleService::abandonedCarts() + * itself, not here.) */ class DetectAbandonedCarts extends Command { @@ -45,33 +52,22 @@ class DetectAbandonedCarts extends Command protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.'; - public function handle(): void + public function handle(CartLifecycleService $lifecycle): void { - $cutoff = CartResource::abandonedCutoff(); - $cartsAbandoned = 0; $checkoutsAbandoned = 0; - Cart::query() - ->whereDoesntHave('orders') - ->where('updated_at', '<=', $cutoff) + $lifecycle->abandonedCarts(Cart::query()) ->where('meta->recovery_consent', true) - ->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) + $lifecycle->abandonedCheckouts(Cart::query()) ->where('meta->recovery_consent', true) ->with(['orders' => fn ($query) => $query->whereNull('placed_at')]) ->chunkById(200, function ($carts) use (&$checkoutsAbandoned) { diff --git a/src/Cart/Filament/Resources/CartResource.php b/src/Cart/Filament/Resources/CartResource.php index bba5a5f..7b22255 100644 --- a/src/Cart/Filament/Resources/CartResource.php +++ b/src/Cart/Filament/Resources/CartResource.php @@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Models\Cart; use Modules\Core\Cart\Filament\Resources\CartResource\Pages; +use Modules\Core\Cart\Services\CartLifecycleService; /** * Read-only — a cart is managed entirely through the storefront (add/update/remove - * line, checkout), never hand-edited by staff. Scoped to carts with a known - * `user_id`/`customer_id` only: an anonymous guest's session cart carries no - * identity a staff member could act on (no name, no email, nothing to follow up - * with), so listing every such row would be noise, not a real admin capability — - * see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all - * to follow a precedent from). + * line, checkout), never hand-edited by staff. Lists every cart, guest carts + * included — see docs/cart.md ("Scope: every cart, identified or not"). An + * anonymous cart's Customer/User columns just render "—" (see table() below) + * rather than the row being hidden outright: most real traffic never reaches + * an identified user/customer, and "how many carts are ongoing/abandoned + * right now" is a real reporting need regardless of identity — excluding + * anonymous carts would silently undercount it. Lunar itself ships no cart + * admin view at all to follow a precedent from. */ class CartResource extends Resource { @@ -36,12 +38,6 @@ class CartResource extends Resource protected static ?string $pluralModelLabel = 'Carts'; - public static function getEloquentQuery(): Builder - { - return parent::getEloquentQuery() - ->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id')); - } - /** * Count only, not a fetch — no rows are loaded. Combines BOTH abandoned * states (`active()` already covers "no order at all" and "draft order, @@ -56,6 +52,11 @@ class CartResource extends Resource return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count(); } + public static function lifecycle(): CartLifecycleService + { + return app(CartLifecycleService::class); + } + /** * `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very * different things together: a cart someone is actively shopping in right now, @@ -67,7 +68,7 @@ class CartResource extends Resource */ public static function abandonedCutoff(): Carbon { - return now()->sub(config('core.cart.abandoned_after', '1 hour')); + return static::lifecycle()->abandonedCutoff(); } public static function table(Table $table): Table diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php index 488c7c1..1d15f05 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php @@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Modules\Core\Cart\Filament\Resources\CartResource; +use Modules\Core\Cart\Services\CartLifecycleService; class ListCarts extends ListRecords { @@ -27,30 +28,24 @@ class ListCarts extends ListRecords * 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. + * The four query shapes below live on Modules\Core\Cart\Services\ + * CartLifecycleService, shared with Modules\Core\Cart\Commands\ + * DetectAbandonedCarts — see that service's docblock for why duplicating + * them independently in both places was worth centralizing. */ public function getTabs(): array { + $lifecycle = app(CartLifecycleService::class); + return [ 'abandoned_cart' => Tab::make('Abandoned Cart') - ->modifyQueryUsing(fn(Builder $query) => $query - ->whereDoesntHave('orders') - ->where('updated_at', '<=', CartResource::abandonedCutoff())), + ->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)), '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())), - + ->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)), 'ongoing' => Tab::make('Ongoing') - ->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), + ->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)), 'completed' => Tab::make('Completed') - ->modifyQueryUsing(fn(Builder $query) => $query->whereHas( - 'orders', - fn(Builder $query) => $query->whereNotNull('placed_at'), - )), + ->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)), ]; } } diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php index 79c5124..f4a4bbb 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php @@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; use Filament\Schemas\Schema; use Filament\Schemas\Components\Section; use Filament\Actions\Action; +use Filament\Infolists\Components\ImageEntry; use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\TextEntry; use Filament\Resources\Pages\ViewRecord; +use Filament\Support\Colors\Color; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Facades\Blade; use Lunar\Admin\Filament\Resources\CustomerResource; +use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; use Lunar\Models\Cart; use Lunar\Models\CartLine; +use Lunar\Models\ProductVariant; use Modules\Core\Cart\Filament\Resources\CartResource; class ViewCart extends ViewRecord @@ -35,12 +41,23 @@ class ViewCart extends ViewRecord * (a single view page load), not per-row in the list table, since running the * full pipeline for every row of a paginated table would be expensive for no * real benefit — see docs/lunar.md's Cart gotchas. + * + * Eager-loads what the Lines section (below) reads off each line's + * purchasable — name, thumbnail, options — the same relations Lunar's + * own OrderItemsTable loads for an order's line items (`with(['purchasable'])`, + * see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so + * rendering the product grid doesn't N+1 per line. */ protected function resolveRecord(int|string $key): Cart { /** @var Cart $cart */ $cart = parent::resolveRecord($key); + $cart->load('lines.purchasable', 'shippingAddress.country'); + + EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant)) + ->loadMissing(['product.thumbnail', 'images', 'values']); + return $cart->calculate(); } @@ -77,6 +94,37 @@ class ViewCart extends ViewRecord RepeatableEntry::make('lines') ->hiddenLabel() ->schema([ + ImageEntry::make('image') + ->hiddenLabel() + ->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant + ? $record->purchasable->getThumbnail()?->getUrl('small') + : null) + ->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode( + Blade::render('') + )) + ->imageSize(48), + TextEntry::make('description') + ->label('Product') + // ProductVariant::getDescription()/getOption() are typed + // string but internally read translateAttribute()/ + // translate(), which return null for a product/option + // with no attribute data set for the active locale — + // reading the underlying relations directly here avoids + // that TypeError rather than calling through them. + ->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant + ? ($record->purchasable->product?->translateAttribute('name') ?? '—') + : '—') + ->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant + ? EditProduct::getUrl(['record' => $record->purchasable->product_id]) + : null) + ->weight('bold'), + TextEntry::make('options') + ->label('Options') + ->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant + ? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null) + : null) + ->placeholder('—') + ->badge(), TextEntry::make('purchasable.sku') ->label('SKU') ->placeholder('—'), @@ -90,6 +138,53 @@ class ViewCart extends ViewRecord ]) ->columns(4), ]), + Section::make('Shipping') + ->columns(3) + ->schema([ + TextEntry::make('shippingAddress.shipping_option') + ->label('Shipping method') + // The raw identifier (e.g. "acs") is all a + // CartAddress row stores — the human-readable + // name only exists on the resolved + // Lunar\DataTypes\ShippingOption, which is what + // shippingBreakdown's items are keyed/named + // from below, so fall back to that name rather + // than showing the bare identifier. + ->formatStateUsing(fn (Cart $record, ?string $state) => $state + ? ($record->shippingBreakdown?->items->get($state)?->name ?? $state) + : null) + ->placeholder('Not selected'), + TextEntry::make('shippingAddress.country.name') + ->label('Shipping to') + ->placeholder('—'), + TextEntry::make('shippingTotal') + ->label('Shipping total') + ->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—') + ->weight('bold'), + RepeatableEntry::make('shippingBreakdownItems') + ->label('Breakdown') + ->columnSpanFull() + // shippingBreakdown->items is a plain (non-Eloquent) + // Collection of Lunar\Base\ValueObjects\Cart\ + // ShippingBreakdownItem — e.g. the carrier rate and, + // separately, Modules\Core\Payment\Pipelines\Cart\ + // ApplyPaymentMethodFee's own line item when the + // selected payment method carries a fee (see + // CHANGELOG 0.16.3) — both show up here individually + // rather than only as the summed shippingTotal above. + ->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? []) + ->schema([ + TextEntry::make('name') + ->hiddenLabel(), + TextEntry::make('price') + ->hiddenLabel() + ->formatStateUsing(fn ($state) => $state?->formatted() ?? '—') + ->alignEnd(), + ]) + ->columns(2) + ->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()), + ]) + ->visible(fn (Cart $record) => $record->shippingAddress !== null), Section::make('Totals') ->columns(3) ->schema([ diff --git a/src/Cart/Services/CartLifecycleService.php b/src/Cart/Services/CartLifecycleService.php new file mode 100644 index 0000000..71f312b --- /dev/null +++ b/src/Cart/Services/CartLifecycleService.php @@ -0,0 +1,99 @@ +recovery_consent`) and non-empty-lines + * filtering stay in DetectAbandonedCarts, not here — those are specific to + * whether a recovery event should fire, not to what "abandoned" means. Staff + * browsing the admin panel should see every abandoned cart, consenting or + * not. + * + * `unrecoverableCutoff()` is a second, older threshold + * (`core.cart.unrecoverable_after`, default 90 days) applied as a lower + * bound on both abandoned*() methods below: a cart past it is too old to be + * a realistic recovery target (pricing/stock/tax have likely moved on), so + * it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than + * staying flagged as an actionable abandonment forever. It does not appear + * in `ongoing()`/`completed()` either — this is about the abandoned-cart + * pipeline specifically, not a retention/deletion policy (no rows are + * touched here). + */ +class CartLifecycleService +{ + public function abandonedCutoff(): Carbon + { + return now()->sub(config('core.cart.abandoned_after', '1 hour')); + } + + public function unrecoverableCutoff(): Carbon + { + return now()->sub(config('core.cart.unrecoverable_after', '90 days')); + } + + /** + * Not yet converted to an order (scopeActive()), with recent activity — + * someone plausibly shopping right now, not (yet) left behind. + */ + public function ongoing(Builder $query): Builder + { + return $query->active()->where('updated_at', '>', $this->abandonedCutoff()); + } + + /** + * No order started at all, stale, not yet past the unrecoverable cap, and + * actually has something in it — the weaker of the two abandoned states + * (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty + * cart (created but nothing ever added — e.g. a bot, or a session that + * never shopped) was never really "abandoned"; there's nothing to + * recover, so it's excluded rather than counted as a false positive. + */ + public function abandonedCarts(Builder $query): Builder + { + return $query->whereDoesntHave('orders') + ->whereHas('lines') + ->where('updated_at', '<=', $this->abandonedCutoff()) + ->where('updated_at', '>', $this->unrecoverableCutoff()); + } + + /** + * A draft order exists (checkout was started) but was never placed, + * stale, and not yet past the unrecoverable cap — the stronger of the + * two abandoned states. + */ + public function abandonedCheckouts(Builder $query): Builder + { + return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at')) + ->where('updated_at', '<=', $this->abandonedCutoff()) + ->where('updated_at', '>', $this->unrecoverableCutoff()); + } + + /** + * Has an order that was actually placed, not just drafted. + */ + public function completed(Builder $query): Builder + { + return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at')); + } +}