visible(); } /** * Search products by query * * @param string $query * @return Builder */ public function search(string $query): Builder { /** @var Builder $query */ $query = Product::where('name', 'like', "%{$query}%") ->orWhere('description', 'like', "%{$query}%"); return $query; } /** * Check if product has available stock for quantity * * @param Product $product * @param int $quantity * @return bool */ public function checkStock(Product $product, int $quantity): bool { if (!$product->manage_stock) { return true; } return $product->getAvailableStock() >= $quantity; } /** * Get available stock for a product * * @param Product $product * @return int */ public function getAvailableStock(Product $product): int { if (!$product->manage_stock) { return PHP_INT_MAX; } return $product->getAvailableStock(); } /** * Check if product is on sale * * @param Product $product * @return bool */ public function isOnSale(Product $product): bool { return $product->isOnSale(); } // ========================================================================= // ORDER QUERIES // ========================================================================= /** * Get all orders query builder. */ public function orders(): Builder { return Order::query(); } /** * Get an order by ID. */ public function order(string $id): ?Order { return Order::find($id); } /** * Get an order by order number. */ public function orderByNumber(string $orderNumber): ?Order { return Order::where('order_number', $orderNumber)->first(); } /** * Get orders created today. */ public function ordersToday(): Builder { return Order::whereDate('created_at', Carbon::today()); } /** * Get orders created this week. */ public function ordersThisWeek(): Builder { return Order::whereBetween('created_at', [ Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek(), ]); } /** * Get orders created this month. */ public function ordersThisMonth(): Builder { return Order::whereBetween('created_at', [ Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth(), ]); } /** * Get orders created this year. */ public function ordersThisYear(): Builder { return Order::whereBetween('created_at', [ Carbon::now()->startOfYear(), Carbon::now()->endOfYear(), ]); } /** * Get orders within a specific date range. */ public function ordersBetween(\DateTimeInterface $from, \DateTimeInterface $until): Builder { return Order::whereBetween('created_at', [$from, $until]); } /** * Get orders with a specific status. */ public function ordersWithStatus(OrderStatus $status): Builder { return Order::where('status', $status->value); } /** * Get pending orders. */ public function pendingOrders(): Builder { return $this->ordersWithStatus(OrderStatus::PENDING); } /** * Get processing orders. */ public function processingOrders(): Builder { return $this->ordersWithStatus(OrderStatus::PROCESSING); } /** * Get completed orders. */ public function completedOrders(): Builder { return $this->ordersWithStatus(OrderStatus::COMPLETED); } /** * Get cancelled orders. */ public function cancelledOrders(): Builder { return $this->ordersWithStatus(OrderStatus::CANCELLED); } /** * Get active orders (not in a final state). */ public function activeOrders(): Builder { return Order::active(); } /** * Get paid orders. */ public function paidOrders(): Builder { return Order::paid(); } /** * Get unpaid orders. */ public function unpaidOrders(): Builder { return Order::unpaid(); } // ========================================================================= // REVENUE & STATISTICS // ========================================================================= /** * Get total revenue (sum of amount_paid across all orders). * Returns value in cents. */ public function totalRevenue(): int { return (int) Order::sum('amount_paid'); } /** * Get revenue for today. * Returns value in cents. */ public function revenueToday(): int { return (int) $this->ordersToday()->sum('amount_paid'); } /** * Get revenue for this week. * Returns value in cents. */ public function revenueThisWeek(): int { return (int) $this->ordersThisWeek()->sum('amount_paid'); } /** * Get revenue for this month. * Returns value in cents. */ public function revenueThisMonth(): int { return (int) $this->ordersThisMonth()->sum('amount_paid'); } /** * Get revenue for this year. * Returns value in cents. */ public function revenueThisYear(): int { return (int) $this->ordersThisYear()->sum('amount_paid'); } /** * Get revenue between dates. * Returns value in cents. */ public function revenueBetween(\DateTimeInterface $from, \DateTimeInterface $until): int { return (int) $this->ordersBetween($from, $until)->sum('amount_paid'); } /** * Get total refunded amount. * Returns value in cents. */ public function totalRefunded(): int { return (int) Order::sum('amount_refunded'); } /** * Get net revenue (total revenue minus refunds). * Returns value in cents. */ public function netRevenue(): int { return $this->totalRevenue() - $this->totalRefunded(); } /** * Get average order value. * Returns value in cents. */ public function averageOrderValue(): float { return (float) Order::avg('amount_total') ?? 0; } /** * Get shop statistics summary. * Optimized to use aggregated queries instead of individual counts. */ public function stats(): array { // Aggregate product counts in single query $productStats = Product::selectRaw(" COUNT(*) as total, SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published, SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft, SUM(CASE WHEN featured = 1 THEN 1 ELSE 0 END) as featured ")->first(); // Aggregate order counts and revenue in single query $today = Carbon::today(); $startOfWeek = Carbon::now()->startOfWeek(); $endOfWeek = Carbon::now()->endOfWeek(); $startOfMonth = Carbon::now()->startOfMonth(); $endOfMonth = Carbon::now()->endOfMonth(); $startOfYear = Carbon::now()->startOfYear(); $endOfYear = Carbon::now()->endOfYear(); $orderStats = Order::selectRaw(" COUNT(*) as total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled, SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END) as today, SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as this_week, SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as this_month, COALESCE(SUM(amount_paid), 0) as total_revenue, COALESCE(SUM(CASE WHEN DATE(created_at) = ? THEN amount_paid ELSE 0 END), 0) as revenue_today, COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_week, COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_month, COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_year, COALESCE(SUM(amount_refunded), 0) as total_refunded, COALESCE(AVG(amount_total), 0) as average_order ", [ OrderStatus::PENDING->value, OrderStatus::PROCESSING->value, OrderStatus::COMPLETED->value, OrderStatus::CANCELLED->value, $today, $startOfWeek, $endOfWeek, $startOfMonth, $endOfMonth, $today, $startOfWeek, $endOfWeek, $startOfMonth, $endOfMonth, $startOfYear, $endOfYear, ])->first(); // Aggregate cart counts in single query $cartStats = Cart::selectRaw(" SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END) as abandoned, SUM(CASE WHEN status = 'expired' THEN 1 ELSE 0 END) as expired, SUM(CASE WHEN converted_at IS NOT NULL THEN 1 ELSE 0 END) as converted ")->first(); $totalRevenue = (int) $orderStats->total_revenue; $totalRefunded = (int) $orderStats->total_refunded; return [ 'products' => [ 'total' => (int) $productStats->total, 'published' => (int) $productStats->published, 'draft' => (int) $productStats->draft, 'featured' => (int) $productStats->featured, ], 'orders' => [ 'total' => (int) $orderStats->total, 'pending' => (int) $orderStats->pending, 'processing' => (int) $orderStats->processing, 'completed' => (int) $orderStats->completed, 'cancelled' => (int) $orderStats->cancelled, 'today' => (int) $orderStats->today, 'this_week' => (int) $orderStats->this_week, 'this_month' => (int) $orderStats->this_month, ], 'revenue' => [ 'total' => $totalRevenue, 'today' => (int) $orderStats->revenue_today, 'this_week' => (int) $orderStats->revenue_this_week, 'this_month' => (int) $orderStats->revenue_this_month, 'this_year' => (int) $orderStats->revenue_this_year, 'refunded' => $totalRefunded, 'net' => $totalRevenue - $totalRefunded, 'average_order' => (float) $orderStats->average_order, ], 'carts' => [ 'active' => (int) $cartStats->active, 'abandoned' => (int) $cartStats->abandoned, 'expired' => (int) $cartStats->expired, 'converted' => (int) $cartStats->converted, ], 'categories' => [ 'total' => ProductCategory::count(), ], ]; } /** * Get revenue grouped by day for a date range. */ public function revenueByDay(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection { return Order::whereBetween('created_at', [$from, $until]) ->selectRaw('DATE(created_at) as date, SUM(amount_paid) as revenue, COUNT(*) as orders') ->groupBy('date') ->orderBy('date') ->get(); } /** * Get revenue grouped by month for a date range. */ public function revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection { return Order::whereBetween('created_at', [$from, $until]) ->selectRaw('YEAR(created_at) as year, MONTH(created_at) as month, SUM(amount_paid) as revenue, COUNT(*) as orders') ->groupBy('year', 'month') ->orderBy('year') ->orderBy('month') ->get(); } /** * Get top selling products. */ public function topProducts(int $limit = 10): Collection { return Product::withCount('purchases') ->orderByDesc('purchases_count') ->limit($limit) ->get(); } // ========================================================================= // SUBSCRIPTION REVENUE (MRR) // ========================================================================= /** * Subscription statuses that count as a live, revenue-bearing subscriber. * * Mirrors Stripe's "active subscribers" metric: a subscription still * billing counts even when it is set to cancel at period end (status stays * `active` with a future `ends_at`) or is retrying payment (`past_due`). * Filtering on `ends_at IS NULL` — a common mistake — silently drops * paying, cancel-at-period-end subscribers and under-reports MRR. * Override via `config('shop.metrics.subscription_statuses')`. * * @return array */ public function subscriberStatuses(): array { return config('shop.metrics.subscription_statuses', ['active', 'past_due', 'trialing']); } /** * Base query for the subscriptions counted in subscriber/MRR metrics. * * @return Builder */ protected function metricSubscriptions(): Builder { $model = config('shop.models.subscription', Subscription::class); return $model::query()->whereIn('stripe_status', $this->subscriberStatuses()); } /** * Number of live subscribers (see {@see subscriberStatuses()}). */ public function activeSubscribers(): int { return $this->metricSubscriptions()->count(); } /** * Monthly Recurring Revenue in cents. * * Every live subscription's line items, each normalized to a monthly * run-rate from its price interval (year ÷ 12, quarter ÷ 3, week/day by the * average-days-per-month constant so weekly/daily match Stripe's own MRR * normalization). Computed from the mirrored {@see ProductPrice} rows — no * Stripe API call — so the total is only as accurate as the mirror: * a `product_prices.unit_amount` that has drifted from the live Stripe * price will skew it, and a line item whose price is not mirrored at all is * excluded. Use {@see subscriptionMetrics()} to see that excluded count * (`unpriced_items`) rather than trusting a silent number, and keep the * mirror synced from Stripe for an exact figure. */ public function mrr(): int { return $this->subscriptionMetrics()['mrr']; } /** * MRR split by product slug, in cents, highest first. * * @return \Illuminate\Support\Collection */ public function mrrByProduct(): \Illuminate\Support\Collection { return collect($this->subscriptionMetrics()['by_product']); } /** * Every subscription metric in a single pass over the live subscriptions: * subscriber count, MRR (cents), MRR by product slug, and the number of * line items whose price could not be resolved from the mirror — so an * under-count is surfaced, never silent. * * @return array{subscribers: int, mrr: int, by_product: array, unpriced_items: int} */ public function subscriptionMetrics(): array { $subscriptions = $this->metricSubscriptions()->with(['items', 'product'])->get(); // Resolve every referenced price in one query, keyed by Stripe price id. $priceModel = config('shop.models.product_price', ProductPrice::class); $priceIds = $subscriptions ->flatMap(fn ($sub) => $sub->items->pluck('stripe_price')) ->filter() ->unique() ->values(); $prices = $priceModel::query() ->whereIn('stripe_price_id', $priceIds->all()) ->with('purchasable') ->get() ->keyBy('stripe_price_id'); $mrr = 0.0; $byProduct = []; $unpricedItems = 0; foreach ($subscriptions as $subscription) { foreach ($subscription->items as $item) { $price = $prices->get($item->stripe_price); if ($price === null) { $unpricedItems++; continue; } $monthly = $this->priceMonthlyAmount($price, (int) ($item->quantity ?? 1)); if ($monthly === null) { // Price resolved but is not recurring (a one-time price on a // subscription line) — carries no MRR, and is not a data gap. continue; } $mrr += $monthly; $slug = $this->priceProductSlug($price) ?? $this->subscriptionProductSlug($subscription) ?? 'unknown'; $byProduct[$slug] = ($byProduct[$slug] ?? 0) + $monthly; } } arsort($byProduct); return [ 'subscribers' => $subscriptions->count(), 'mrr' => (int) round($mrr), 'by_product' => array_map(static fn ($cents) => (int) round($cents), $byProduct), 'unpriced_items' => $unpricedItems, ]; } /** * Normalize a recurring price to a monthly amount in cents for `$quantity` * units. Returns null for a non-recurring (one-time) price, which carries * no MRR. Weekly and daily use the Gregorian average-days-per-month * constant so they line up with Cashier/Stripe's MRR normalization. */ protected function priceMonthlyAmount(ProductPrice $price, int $quantity = 1): ?int { $interval = $price->interval instanceof RecurringInterval ? $price->interval->value : (is_string($price->interval) ? $price->interval : null); if ($interval === null) { return null; // one-time price — not recurring revenue } $amount = (float) $price->unit_amount * max(1, $quantity); $count = max(1, (int) ($price->interval_count ?: 1)); $daysPerMonth = 30.436875; // Gregorian average month length $monthly = match ($interval) { 'year' => $amount / (12 * $count), 'quarter' => $amount / (3 * $count), 'month' => $amount / $count, 'week' => $amount / ($count * 7 / $daysPerMonth), 'day' => $amount / ($count / $daysPerMonth), default => null, }; return $monthly === null ? null : (int) round($monthly); } /** * Product slug carried by a price's purchasable (usually a Product), or * null when the purchasable is absent or has no slug/name. */ protected function priceProductSlug(ProductPrice $price): ?string { $purchasable = $price->purchasable; if (! is_object($purchasable)) { return null; } return $purchasable->slug ?? $purchasable->name ?? null; } /** * Product slug for the subscription's linked product, or null. */ protected function subscriptionProductSlug(Subscription $subscription): ?string { $product = $subscription->product; if (! is_object($product)) { return null; } return $product->slug ?? $product->name ?? null; } // ========================================================================= // CART QUERIES // ========================================================================= /** * Get all carts query builder. */ public function carts(): Builder { return Cart::query(); } /** * Get active carts. */ public function activeCarts(): Builder { return Cart::where('status', 'active'); } /** * Get abandoned carts. */ public function abandonedCarts(): Builder { return Cart::where('status', 'abandoned'); } /** * Get expired carts. */ public function expiredCarts(): Builder { return Cart::where('status', 'expired'); } /** * Get carts that should be marked as expired (inactive for more than 1 hour). */ public function cartsToExpire(): Builder { $expirationMinutes = config('shop.cart.expiration_minutes', 60); return Cart::where('status', 'active') ->where(function ($query) use ($expirationMinutes) { $query->where('last_activity_at', '<', Carbon::now()->subMinutes($expirationMinutes)) ->orWhere(function ($q) use ($expirationMinutes) { $q->whereNull('last_activity_at') ->where('updated_at', '<', Carbon::now()->subMinutes($expirationMinutes)); }); }); } /** * Get carts that should be deleted (unused for more than 24 hours). */ public function cartsToDelete(): Builder { $deletionHours = config('shop.cart.deletion_hours', 24); return Cart::where('status', '!=', 'converted') ->whereNull('converted_at') ->where(function ($query) use ($deletionHours) { $query->where('last_activity_at', '<', Carbon::now()->subHours($deletionHours)) ->orWhere(function ($q) use ($deletionHours) { $q->whereNull('last_activity_at') ->where('updated_at', '<', Carbon::now()->subHours($deletionHours)); }); }); } /** * Expire stale carts (inactive for more than 1 hour). * Returns the number of carts expired. */ public function expireStaleCarts(): int { return $this->cartsToExpire()->update([ 'status' => 'expired', ]); } /** * Delete old unused carts (unused for more than 24 hours). * Returns the number of carts deleted. */ public function deleteOldCarts(): int { // Get cart IDs to delete $cartIds = $this->cartsToDelete()->pluck('id')->toArray(); if (empty($cartIds)) { return 0; } // Delete cart items in batch first (foreign key constraint) CartItem::whereIn('cart_id', $cartIds)->delete(); // Delete carts in batch return Cart::whereIn('id', $cartIds)->delete(); } // ========================================================================= // CONFIGURATION HELPERS // ========================================================================= /** * Get shop configuration value * * @param string $key * @param mixed $default * @return mixed */ public function config(string $key, $default = null) { return config("shop.{$key}", $default); } /** * Get default shop currency * * @return string */ public function currency(): string { return config('shop.currency', 'USD'); } /** * Format money amount (from cents to display format). */ public function formatMoney(int $cents, ?string $currency = null): string { $currency = $currency ?? $this->currency(); $amount = $cents / 100; return number_format($amount, 2) . ' ' . strtoupper($currency); } }