visible(); } /** * Search products by query */ 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 */ public function checkStock(Product $product, int $quantity): bool { if (! $product->manage_stock) { return true; } return $product->getAvailableStock() >= $quantity; } /** * Get available stock for a product */ public function getAvailableStock(Product $product): int { if (! $product->manage_stock) { return PHP_INT_MAX; } return $product->getAvailableStock(); } /** * Check if product is on sale */ public function isOnSale(Product $product): bool { return $product->isOnSale(); } // ========================================================================= // ORDER QUERIES // ========================================================================= /** * The configured {@see Order} model class (`shop.models.order`). * * All order queries below resolve it instead of using `Order::` statically, * so a host subclass is what every finder returns. * * @return class-string */ protected function orderModel(): string { return config('shop.models.order', Order::class); } /** * Get all orders query builder. */ public function orders(): Builder { return $this->orderModel()::query(); } /** * Get an order by ID. */ public function order(string $id): ?Order { return $this->orderModel()::find($id); } /** * Get an order by order number. */ public function orderByNumber(string $orderNumber): ?Order { return $this->orderModel()::where('order_number', $orderNumber)->first(); } /** * Get orders created today. */ public function ordersToday(): Builder { return $this->orderModel()::whereDate('created_at', Carbon::today()); } /** * Get orders created this week. */ public function ordersThisWeek(): Builder { return $this->orderModel()::whereBetween('created_at', [ Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek(), ]); } /** * Get orders created this month. */ public function ordersThisMonth(): Builder { return $this->orderModel()::whereBetween('created_at', [ Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth(), ]); } /** * Get orders created this year. */ public function ordersThisYear(): Builder { return $this->orderModel()::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 $this->orderModel()::whereBetween('created_at', [$from, $until]); } /** * Get orders with a specific status. */ public function ordersWithStatus(OrderStatus $status): Builder { return $this->orderModel()::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 $this->orderModel()::active(); } /** * Get paid orders. */ public function paidOrders(): Builder { return $this->orderModel()::paid(); } /** * Get unpaid orders. */ public function unpaidOrders(): Builder { return $this->orderModel()::unpaid(); } // ========================================================================= // REVENUE & STATISTICS // ========================================================================= /** * Get total revenue (sum of amount_paid across all orders). * Returns value in cents. */ public function totalRevenue(): int { return (int) $this->orderModel()::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) $this->orderModel()::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) $this->orderModel()::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 = $this->orderModel()::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 $this->orderModel()::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 $this->orderModel()::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; $cogs = 0.0; $license = 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; } $quantity = (int) ($item->quantity ?? 1); $monthly = $this->priceMonthlyAmount($price, $quantity); 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; $cogs += $this->priceMonthlyCost($price, $quantity) ?? 0; $license += $this->priceMonthlyLicense($price, $quantity) ?? 0; $slug = $this->priceProductSlug($price) ?? $this->subscriptionProductSlug($subscription) ?? 'unknown'; $byProduct[$slug] = ($byProduct[$slug] ?? 0) + $monthly; } } arsort($byProduct); return [ 'subscribers' => $subscriptions->count(), 'mrr' => (int) round($mrr), 'cogs' => (int) round($cogs), 'license_cost' => (int) round($license), 'net_mrr' => (int) round($mrr - $cogs - $license), 'by_product' => array_map(static fn ($cents) => (int) round($cents), $byProduct), 'unpriced_items' => $unpricedItems, ]; } /** * Net Monthly Recurring Revenue in cents: {@see mrr()} minus recurring cost * of goods (`cost_amount`) minus the amortized royalty / minimum licence fee * ({@see priceMonthlyLicense()}). The recurring contribution margin — MRR * after everything it costs to deliver the subscription. */ public function netMrr(): int { return $this->subscriptionMetrics()['net_mrr']; } /** * Normalize a recurring price's `unit_amount` to a monthly run-rate in cents * for `$quantity` units. Null for a non-recurring (one-time) price. */ protected function priceMonthlyAmount(ProductPrice $price, int $quantity = 1): ?int { return $this->monthlyForPrice($price, (float) $price->unit_amount, $quantity); } /** * Normalize a recurring price's `cost_amount` (COGS — license/shipping/etc.) * to a monthly figure in cents for `$quantity` units. Null when the price is * one-time; 0 when it carries no cost basis. */ protected function priceMonthlyCost(ProductPrice $price, int $quantity = 1): ?int { return $this->monthlyForPrice($price, (float) ($price->cost_amount ?? 0), $quantity); } /** * Shared interval normalization: turn `$amountCents` charged/incurred once * per the price's billing period into a monthly run-rate for `$quantity` * units. Weekly and daily use the Gregorian average-days-per-month constant * so they line up with Cashier/Stripe's MRR normalization. Returns null for * a non-recurring (one-time) price. */ protected function monthlyForPrice(ProductPrice $price, float $amountCents, 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 } $amount = $amountCents * 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); } /** * Monthly run-rate (cents) of the royalty / minimum licence fee for a * recurring price. The fee ({@see ProductPrice::licenseFeePerPeriod()}) is * owed once per licence-term bracket — 1–3 months, 6 months, or 12 months — * so it is amortized over that bracket, NOT per billing cycle. That is why a * monthly subscriber renewing three times in a quarter still costs one * quarterly fee: a €25.50/mo Full Seat with a €15 minimum contributes €15 ÷ * 3 = €5/month. Null for a non-recurring price; 0 when there is no rule. */ protected function priceMonthlyLicense(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 — subscription licence run-rate N/A } $perPeriod = $price->licenseFeePerPeriod(); if ($perPeriod <= 0) { return 0; } return (int) round($perPeriod * max(1, $quantity) / $this->licenseBracketMonths($price)); } /** * The licence-term bracket a price falls in, in months: its term (interval × * interval_count) snapped to 3 (the ≤ quarter accounting bracket), 6, or 12. * The quarterly accounting period is the smallest bracket, so any * sub-quarterly billing (monthly/weekly) amortizes over 3 months. */ protected function licenseBracketMonths(ProductPrice $price): int { $interval = $price->interval instanceof RecurringInterval ? $price->interval->value : (is_string($price->interval) ? $price->interval : null); $count = max(1, (int) ($price->interval_count ?: 1)); $daysPerMonth = 30.436875; $termMonths = match ($interval) { 'year' => 12 * $count, 'quarter' => 3 * $count, 'month' => $count, 'week' => $count * 7 / $daysPerMonth, 'day' => $count / $daysPerMonth, default => 1, }; return $termMonths <= 3 ? 3 : ($termMonths <= 6 ? 6 : 12); } /** * 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 mixed $default * @return mixed */ public function config(string $key, $default = null) { return config("shop.{$key}", $default); } /** * Get default shop currency */ 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); } // ========================================================================= // STRIPE LEDGER (net-of-fees, user-independent) // ========================================================================= /** * The configured {@see StripeTransaction} model class. * * @return class-string */ protected function ledgerModel(): string { return config('shop.models.stripe_transaction', StripeTransaction::class); } /** * BalanceTransaction `type`s that count as revenue (net of fees/refunds). * * @return array */ public function ledgerRevenueTypes(): array { return config('shop.ledger.revenue_types', [ 'charge', 'payment', 'refund', 'payment_refund', 'adjustment', 'dispute', 'dispute_reversal', ]); } /** * The earliest ledger row's Stripe `created` timestamp — the "start" of the * business's money history, for an all-time cumulative view. Null when empty. */ public function ledgerEarliest(): ?Carbon { $min = $this->ledgerModel()::query()->min('created'); return $min ? Carbon::parse($min) : null; } /** * Revenue-bearing ledger movements grouped by Stripe day, in cents. * * One row per day with signed `gross` (SUM amount), `fee` (SUM fee) and * `net` (SUM net = the money kept after fees and refunds) plus the row * `count`. Refund/dispute days push `gross`/`net` down. Feed the `net` * column into a cumulative sum for a break-even balance line. * * @return \Illuminate\Support\Collection */ public function revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection { return $this->ledgerModel()::query() ->whereBetween('created', [$from, $until]) ->whereIn('source_type', $this->ledgerRevenueTypes()) ->selectRaw('DATE(created) as date, SUM(amount) as gross, SUM(fee) as fee, SUM(net) as net, COUNT(*) as count') ->groupBy('date') ->orderBy('date') ->get() ->map(fn ($r) => (object) [ 'date' => (string) $r->date, 'gross' => (int) $r->gross, 'fee' => (int) $r->fee, 'net' => (int) $r->net, 'count' => (int) $r->count, ]); } /** * Window totals over the revenue-bearing ledger, in cents: gross sales * (positive charge lines), the negative refund total, Stripe fees, and the * resulting net collected. `count` is the number of ledger rows in range. * * @return array{gross: int, refunds: int, fees: int, net: int, count: int} */ public function ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until): array { // CASE WHEN (not GREATEST/LEAST) so the split is portable to SQLite tests. $row = $this->ledgerModel()::query() ->whereBetween('created', [$from, $until]) ->whereIn('source_type', $this->ledgerRevenueTypes()) ->selectRaw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as gross') ->selectRaw('SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) as refunds') ->selectRaw('SUM(fee) as fees') ->selectRaw('SUM(net) as net') ->selectRaw('COUNT(*) as count') ->first(); return [ 'gross' => (int) ($row->gross ?? 0), 'refunds' => (int) ($row->refunds ?? 0), 'fees' => (int) ($row->fees ?? 0), 'net' => (int) ($row->net ?? 0), 'count' => (int) ($row->count ?? 0), ]; } /** * Money one buyer actually paid us, attributed from the user-independent * ledger by ANY of their Stripe customer id(s) OR buyer email(s) — so a * customer whose local user was deleted, or who paid as a guest before * signing up, is still attributed. Returns the signed split in cents. * * `amount` = SUM(amount) over revenue-type rows = gross charges minus * refunds: the euros we kept, before Stripe's per-charge fee. This is the * figure that catches subscription payments the per-user product_purchases * sum misses (they are stored there as amount=0). * * @param string|array $customerIds Stripe customer id(s) (cus_…) * @param string|array $emails buyer email(s) * @return array{amount: int, gross: int, refunds: int, fees: int, net: int, count: int} */ public function customerLedgerTotals( string|array $customerIds = [], string|array $emails = [], ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, ): array { $customerIds = array_values(array_filter((array) $customerIds, static fn ($v) => is_string($v) && $v !== '')); $emails = array_values(array_filter((array) $emails, static fn ($v) => is_string($v) && $v !== '')); $zero = ['amount' => 0, 'gross' => 0, 'refunds' => 0, 'fees' => 0, 'net' => 0, 'count' => 0]; // No identity to match on → nothing attributable. Guard so an empty // whereIn can never degrade into a match-everything. if (! $customerIds && ! $emails) { return $zero; } $row = $this->ledgerModel()::query() ->whereIn('source_type', $this->ledgerRevenueTypes()) ->where(function ($q) use ($customerIds, $emails) { if ($customerIds) { $q->orWhereIn('customer_id', $customerIds); } if ($emails) { $q->orWhereIn('customer_email', $emails); } }) ->when($from, fn ($q) => $q->where('created', '>=', $from)) ->when($until, fn ($q) => $q->where('created', '<=', $until)) ->selectRaw('SUM(amount) as amount') ->selectRaw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as gross') ->selectRaw('SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) as refunds') ->selectRaw('SUM(fee) as fees') ->selectRaw('SUM(net) as net') ->selectRaw('COUNT(*) as count') ->first(); return [ 'amount' => (int) ($row->amount ?? 0), 'gross' => (int) ($row->gross ?? 0), 'refunds' => (int) ($row->refunds ?? 0), 'fees' => (int) ($row->fees ?? 0), 'net' => (int) ($row->net ?? 0), 'count' => (int) ($row->count ?? 0), ]; } /** * Money received (net of refunds) grouped by Stripe customer id, in cents: * `[customer_id => amount]` over the revenue-bearing ledger. The caller maps * customer ids to local users (e.g. users.stripe_id) to attribute revenue * per user in one pass. Rows with no customer id (a guest charge carrying * only an email) are omitted here — attribute those by email via * {@see customerLedgerTotals}. * * @return \Illuminate\Support\Collection */ public function ledgerAmountByCustomer( ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, ): \Illuminate\Support\Collection { return $this->ledgerModel()::query() ->whereIn('source_type', $this->ledgerRevenueTypes()) ->whereNotNull('customer_id') ->when($from, fn ($q) => $q->where('created', '>=', $from)) ->when($until, fn ($q) => $q->where('created', '<=', $until)) ->groupBy('customer_id') ->selectRaw('customer_id, SUM(amount) as amount') ->pluck('amount', 'customer_id') ->map(fn ($v) => (int) $v); } /** * Flatten a Stripe BalanceTransaction into a ledger row. Pass an explicit * `$source` (the Charge/Refund already retrieved for a webhook) to attribute * the buyer; otherwise the transaction's own expanded `source` is used (the * importer path). A refund's buyer is read from its underlying charge so * refunds net against the right customer. * * @return array */ public function ledgerRowFromBalanceTransaction($txn, $source = null): array { $source = is_object($source) ? $source : (is_object($txn->source ?? null) ? $txn->source : null); $sourceId = $source?->id ?? (is_string($txn->source ?? null) ? $txn->source : null); $chargeSource = is_object($source->charge ?? null) ? $source->charge : null; $customerId = null; $customerEmail = null; foreach ([$source, $chargeSource] as $s) { if (! is_object($s)) { continue; } $customerId = $customerId ?? ($s->customer ?? null); $customerEmail = $customerEmail ?? ($s->billing_details->email ?? null) ?? ($s->receipt_email ?? null) ?? ($s->customer_email ?? null); } return [ 'stripe_id' => $txn->id, 'source_type' => $txn->type ?? null, 'reporting_category' => $txn->reporting_category ?? null, 'source_id' => $sourceId, 'amount' => (int) ($txn->amount ?? 0), 'fee' => (int) ($txn->fee ?? 0), 'net' => (int) ($txn->net ?? 0), 'currency' => $txn->currency ?? null, 'customer_id' => is_string($customerId) ? $customerId : null, 'customer_email' => is_string($customerEmail) ? $customerEmail : null, 'description' => $txn->description ?? null, 'created' => isset($txn->created) ? Carbon::createFromTimestamp($txn->created) : null, 'available_on' => isset($txn->available_on) ? Carbon::createFromTimestamp($txn->available_on) : null, 'meta' => [ 'status' => $txn->status ?? null, 'fee_details' => isset($txn->fee_details) ? json_decode(json_encode($txn->fee_details), true) : null, ], ]; } /** * Idempotently upsert one BalanceTransaction into the ledger (key = `txn_` * id). Never downgrades a known customer_id/email back to null — so the * real-time webhook, which resolves the buyer, is not clobbered by a later * importer pass that couldn't. Returns 'created' or 'updated'. */ public function recordBalanceTransaction($txn, $source = null): string { $row = $this->ledgerRowFromBalanceTransaction($txn, $source); $existing = $this->ledgerModel()::query()->where('stripe_id', $row['stripe_id'])->first(); if ($existing) { if ($row['customer_id'] === null && $existing->customer_id !== null) { $row['customer_id'] = $existing->customer_id; } if ($row['customer_email'] === null && $existing->customer_email !== null) { $row['customer_email'] = $existing->customer_email; } $existing->fill($row)->save(); return 'updated'; } $this->ledgerModel()::query()->create($row); return 'created'; } /** * Real-time ledger sync for a single charge — the webhook's main path. * Retrieves the charge with its balance transaction and any refunds' balance * transactions and upserts each, user-independent (a deleted customer's money * is still recorded). Returns the number of rows touched. Never throws: on * any Stripe/DB error it logs and returns 0, so it can't break the webhook. */ public function syncLedgerForCharge(?string $chargeId): int { if (! is_string($chargeId) || $chargeId === '' || ! str_starts_with($chargeId, 'ch_')) { return 0; } $secret = config('services.stripe.secret') ?: config('cashier.secret') ?: env('STRIPE_SECRET'); if (! $secret) { return 0; } try { Stripe::setApiKey($secret); $charge = Charge::retrieve([ 'id' => $chargeId, 'expand' => ['balance_transaction', 'refunds.data.balance_transaction'], ]); $count = 0; if (is_object($charge->balance_transaction ?? null)) { $this->recordBalanceTransaction($charge->balance_transaction, $charge); $count++; } foreach (($charge->refunds->data ?? []) as $refund) { if (is_object($refund->balance_transaction ?? null)) { // Attribute the refund to the charge's buyer. $this->recordBalanceTransaction($refund->balance_transaction, $charge); $count++; } } return $count; } catch (\Throwable $e) { Log::warning('[shop:ledger] syncLedgerForCharge failed', [ 'charge' => $chargeId, 'error' => $e->getMessage(), ]); return 0; } } }