recovery_consent = true — CartAbandoned/ * CheckoutAbandoned exist specifically to drive future recovery-email * 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. */ 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) ->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) ->where('meta->recovery_consent', true) ->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)."); } }