laravel-shop/src/Services/ShopService.php

850 lines
25 KiB
PHP
Raw Normal View History

2025-12-09 09:59:46 +00:00
<?php
declare(strict_types=1);
2025-12-09 09:59:46 +00:00
namespace Blax\Shop\Services;
2025-12-29 09:26:51 +00:00
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\RecurringInterval;
2025-12-29 09:26:51 +00:00
use Blax\Shop\Models\Cart;
2025-12-29 10:11:27 +00:00
use Blax\Shop\Models\CartItem;
2025-12-29 09:26:51 +00:00
use Blax\Shop\Models\Order;
2025-12-09 09:59:46 +00:00
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPrice;
2025-12-29 09:26:51 +00:00
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Subscription;
2025-12-09 09:59:46 +00:00
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Carbon\Carbon;
2025-12-09 09:59:46 +00:00
class ShopService
{
2025-12-29 09:26:51 +00:00
// =========================================================================
// PRODUCT QUERIES
// =========================================================================
2025-12-09 09:59:46 +00:00
/**
* Get all products query builder
*
* @return Builder
*/
public function products(): Builder
{
return Product::query();
}
/**
* Get a product by ID
*
* @param mixed $id
* @return Product|null
*/
public function product($id): ?Product
{
return Product::find($id);
}
/**
* Get all categories query builder
*
* @return Builder
*/
public function categories(): Builder
{
return ProductCategory::query();
}
/**
* Get in-stock products
*
* @return Builder
*/
public function inStock(): Builder
{
return Product::inStock();
}
/**
* Get featured products
*
* @return Builder
*/
public function featured(): Builder
{
return Product::featured();
}
/**
* Get published and visible products
*
* @return Builder
*/
public function published(): Builder
{
return Product::published()->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();
}
2025-12-29 09:26:51 +00:00
// =========================================================================
// 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.
2025-12-29 10:11:27 +00:00
* Optimized to use aggregated queries instead of individual counts.
2025-12-29 09:26:51 +00:00
*/
public function stats(): array
{
2025-12-29 10:11:27 +00:00
// 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;
2025-12-29 09:26:51 +00:00
return [
'products' => [
2025-12-29 10:11:27 +00:00
'total' => (int) $productStats->total,
'published' => (int) $productStats->published,
'draft' => (int) $productStats->draft,
'featured' => (int) $productStats->featured,
2025-12-29 09:26:51 +00:00
],
'orders' => [
2025-12-29 10:11:27 +00:00
'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,
2025-12-29 09:26:51 +00:00
],
'revenue' => [
2025-12-29 10:11:27 +00:00
'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,
2025-12-29 09:26:51 +00:00
],
'carts' => [
2025-12-29 10:11:27 +00:00
'active' => (int) $cartStats->active,
'abandoned' => (int) $cartStats->abandoned,
'expired' => (int) $cartStats->expired,
'converted' => (int) $cartStats->converted,
2025-12-29 09:26:51 +00:00
],
'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<int, string>
*/
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<Subscription>
*/
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<string, int>
*/
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<string, int>, 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;
}
2025-12-29 09:26:51 +00:00
// =========================================================================
// 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
{
2025-12-29 10:11:27 +00:00
// Get cart IDs to delete
$cartIds = $this->cartsToDelete()->pluck('id')->toArray();
2025-12-29 09:26:51 +00:00
2025-12-29 10:11:27 +00:00
if (empty($cartIds)) {
return 0;
2025-12-29 09:26:51 +00:00
}
2025-12-29 10:11:27 +00:00
// 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();
2025-12-29 09:26:51 +00:00
}
// =========================================================================
// CONFIGURATION HELPERS
// =========================================================================
2025-12-09 09:59:46 +00:00
/**
* 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');
}
2025-12-29 09:26:51 +00:00
/**
* 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);
}
2025-12-09 09:59:46 +00:00
}