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. 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 { protected $signature = 'boboko:cart:detect-abandoned'; protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.'; public function handle(CartLifecycleService $lifecycle): void { $cartsAbandoned = 0; $checkoutsAbandoned = 0; $lifecycle->abandonedCarts(Cart::query()) ->where('meta->recovery_consent', true) ->chunkById(200, function ($carts) use (&$cartsAbandoned) { foreach ($carts as $cart) { Event::dispatch(new CartAbandoned($cart)); $cartsAbandoned++; } }); $lifecycle->abandonedCheckouts(Cart::query()) ->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)."); } }