I order, A handy methods

This commit is contained in:
Fabian @ Blax Software 2025-12-29 10:26:51 +01:00
parent 9c1fcd6cfd
commit 6e9c9043ae
12 changed files with 2217 additions and 14 deletions

View File

@ -113,6 +113,12 @@ return [
'expire_after_days' => 30,
'auto_cleanup' => true,
'merge_on_login' => true,
// Cart expiration: mark carts as expired after this many minutes of inactivity
'expiration_minutes' => env('SHOP_CART_EXPIRATION_MINUTES', 60),
// Cart deletion: delete unused carts after this many hours of inactivity
'deletion_hours' => env('SHOP_CART_DELETION_HOURS', 24),
],
// Order configuration

View File

@ -0,0 +1,132 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Facades\Shop;
use Blax\Shop\Models\Cart;
use Illuminate\Console\Command;
class ShopCleanupCartsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'shop:cleanup-carts
{--expire : Only expire stale carts without deleting}
{--delete : Only delete old carts without expiring}
{--dry-run : Show what would be done without making changes}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Expire stale carts and delete old unused carts';
/**
* Execute the console command.
*/
public function handle(): int
{
$onlyExpire = $this->option('expire');
$onlyDelete = $this->option('delete');
$dryRun = $this->option('dry-run');
$force = $this->option('force');
// If neither flag is set, do both
$doExpire = !$onlyDelete || $onlyExpire;
$doDelete = !$onlyExpire || $onlyDelete;
$expirationMinutes = config('shop.cart.expiration_minutes', 60);
$deletionHours = config('shop.cart.deletion_hours', 24);
$this->info('Cart Cleanup');
$this->info('============');
$this->newLine();
// Show configuration
$this->info("Configuration:");
$this->line(" • Expiration threshold: {$expirationMinutes} minutes of inactivity");
$this->line(" • Deletion threshold: {$deletionHours} hours of inactivity");
$this->newLine();
$expiredCount = 0;
$deletedCount = 0;
// Handle expiration
if ($doExpire) {
$cartsToExpire = Cart::shouldExpire()->get();
$expiredCount = $cartsToExpire->count();
$this->info("Carts to expire: {$expiredCount}");
if ($expiredCount > 0) {
if ($dryRun) {
$this->warn(" [DRY RUN] Would expire {$expiredCount} carts");
$this->table(
['ID', 'Customer', 'Items', 'Last Activity', 'Created'],
$cartsToExpire->map(fn($cart) => [
substr($cart->id, 0, 8) . '...',
$cart->customer_id ? substr($cart->customer_id, 0, 8) . '...' : 'Guest',
$cart->items()->count(),
$cart->last_activity_at?->diffForHumans() ?? $cart->updated_at->diffForHumans(),
$cart->created_at->diffForHumans(),
])->toArray()
);
} else {
Shop::expireStaleCarts();
$this->info(" ✓ Expired {$expiredCount} carts");
}
}
$this->newLine();
}
// Handle deletion
if ($doDelete) {
$cartsToDelete = Cart::shouldDelete()->get();
$deletedCount = $cartsToDelete->count();
$this->info("Carts to delete: {$deletedCount}");
if ($deletedCount > 0) {
if ($dryRun) {
$this->warn(" [DRY RUN] Would delete {$deletedCount} carts");
$this->table(
['ID', 'Status', 'Customer', 'Items', 'Last Activity', 'Created'],
$cartsToDelete->map(fn($cart) => [
substr($cart->id, 0, 8) . '...',
$cart->status->value,
$cart->customer_id ? substr($cart->customer_id, 0, 8) . '...' : 'Guest',
$cart->items()->count(),
$cart->last_activity_at?->diffForHumans() ?? $cart->updated_at->diffForHumans(),
$cart->created_at->diffForHumans(),
])->toArray()
);
} else {
if (!$force && !$this->confirm("Delete {$deletedCount} carts permanently?")) {
$this->info('Deletion cancelled.');
return self::SUCCESS;
}
Shop::deleteOldCarts();
$this->info(" ✓ Deleted {$deletedCount} carts");
}
}
$this->newLine();
}
// Summary
$this->info('Summary');
$this->info('-------');
if ($dryRun) {
$this->warn('[DRY RUN] No changes were made');
}
$this->line(" • Carts expired: {$expiredCount}");
$this->line(" • Carts deleted: {$deletedCount}");
return self::SUCCESS;
}
}

View File

@ -5,6 +5,9 @@ namespace Blax\Shop\Facades;
use Illuminate\Support\Facades\Facade;
/**
* Shop Facade - Admin and Developer helper methods.
*
* Product Queries:
* @method static \Illuminate\Database\Eloquent\Builder products()
* @method static \Blax\Shop\Models\Product|null product(mixed $id)
* @method static \Illuminate\Database\Eloquent\Builder categories()
@ -15,8 +18,54 @@ use Illuminate\Support\Facades\Facade;
* @method static bool checkStock(\Blax\Shop\Models\Product $product, int $quantity)
* @method static int getAvailableStock(\Blax\Shop\Models\Product $product)
* @method static bool isOnSale(\Blax\Shop\Models\Product $product)
* @method static \Illuminate\Database\Eloquent\Collection topProducts(int $limit = 10)
*
* Order Queries:
* @method static \Illuminate\Database\Eloquent\Builder orders()
* @method static \Blax\Shop\Models\Order|null order(string $id)
* @method static \Blax\Shop\Models\Order|null orderByNumber(string $orderNumber)
* @method static \Illuminate\Database\Eloquent\Builder ordersToday()
* @method static \Illuminate\Database\Eloquent\Builder ordersThisWeek()
* @method static \Illuminate\Database\Eloquent\Builder ordersThisMonth()
* @method static \Illuminate\Database\Eloquent\Builder ordersThisYear()
* @method static \Illuminate\Database\Eloquent\Builder ordersBetween(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static \Illuminate\Database\Eloquent\Builder ordersWithStatus(\Blax\Shop\Enums\OrderStatus $status)
* @method static \Illuminate\Database\Eloquent\Builder pendingOrders()
* @method static \Illuminate\Database\Eloquent\Builder processingOrders()
* @method static \Illuminate\Database\Eloquent\Builder completedOrders()
* @method static \Illuminate\Database\Eloquent\Builder cancelledOrders()
* @method static \Illuminate\Database\Eloquent\Builder activeOrders()
* @method static \Illuminate\Database\Eloquent\Builder paidOrders()
* @method static \Illuminate\Database\Eloquent\Builder unpaidOrders()
*
* Revenue & Statistics:
* @method static int totalRevenue()
* @method static int revenueToday()
* @method static int revenueThisWeek()
* @method static int revenueThisMonth()
* @method static int revenueThisYear()
* @method static int revenueBetween(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static int totalRefunded()
* @method static int netRevenue()
* @method static float averageOrderValue()
* @method static array stats()
* @method static \Illuminate\Support\Collection revenueByDay(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static \Illuminate\Support\Collection revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until)
*
* Cart Queries:
* @method static \Illuminate\Database\Eloquent\Builder carts()
* @method static \Illuminate\Database\Eloquent\Builder activeCarts()
* @method static \Illuminate\Database\Eloquent\Builder abandonedCarts()
* @method static \Illuminate\Database\Eloquent\Builder expiredCarts()
* @method static \Illuminate\Database\Eloquent\Builder cartsToExpire()
* @method static \Illuminate\Database\Eloquent\Builder cartsToDelete()
* @method static int expireStaleCarts()
* @method static int deleteOldCarts()
*
* Configuration:
* @method static mixed config(string $key, mixed $default = null)
* @method static string currency()
* @method static string formatMoney(int $cents, ?string $currency = null)
*/
class Shop extends Facade
{

View File

@ -69,11 +69,143 @@ class Cart extends Model
protected static function booted()
{
// Auto-update last_activity_at on creation
static::creating(function ($cart) {
if (empty($cart->last_activity_at)) {
$cart->last_activity_at = now();
}
});
static::deleting(function ($cart) {
$cart->items()->delete();
});
}
/**
* Touch the cart's last activity timestamp.
* Call this whenever there's activity on the cart.
*/
public function touchActivity(): self
{
$this->last_activity_at = now();
$this->saveQuietly(); // Don't trigger events
return $this;
}
/**
* Check if the cart has expired.
* Checks both explicit expires_at and activity-based expiration.
*/
public function isExpired(): bool
{
// Check if status is explicitly expired
if ($this->status === CartStatus::EXPIRED) {
return true;
}
// Check if explicitly expired via expires_at column
if ($this->expires_at && $this->expires_at->lt(now())) {
return true;
}
// Check activity-based expiration
$expirationMinutes = config('shop.cart.expiration_minutes', 60);
$lastActivity = $this->last_activity_at ?? $this->updated_at;
return $lastActivity && $lastActivity->lt(now()->subMinutes($expirationMinutes));
}
/**
* Scope to get active (non-expired, non-converted) carts.
*/
public function scopeActive($query)
{
return $query->whereNull('converted_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Check if the cart should be deleted (unused for more than the configured time).
*/
public function shouldBeDeleted(): bool
{
// Never delete converted carts
if ($this->converted_at || $this->status === CartStatus::CONVERTED) {
return false;
}
$deletionHours = config('shop.cart.deletion_hours', 24);
$lastActivity = $this->last_activity_at ?? $this->updated_at;
return $lastActivity && $lastActivity->lt(now()->subHours($deletionHours));
}
/**
* Mark the cart as expired.
*/
public function markAsExpired(): self
{
$this->status = CartStatus::EXPIRED;
$this->save();
return $this;
}
/**
* Mark the cart as abandoned.
*/
public function markAsAbandoned(): self
{
$this->status = CartStatus::ABANDONED;
$this->save();
return $this;
}
/**
* Scope to get carts that should expire (inactive for more than configured time).
*/
public function scopeShouldExpire($query)
{
$expirationMinutes = config('shop.cart.expiration_minutes', 60);
return $query->where('status', CartStatus::ACTIVE->value)
->where(function ($q) use ($expirationMinutes) {
$q->where('last_activity_at', '<', now()->subMinutes($expirationMinutes))
->orWhere(function ($q2) use ($expirationMinutes) {
$q2->whereNull('last_activity_at')
->where('updated_at', '<', now()->subMinutes($expirationMinutes));
});
});
}
/**
* Scope to get carts that should be deleted (unused for more than configured time).
*/
public function scopeShouldDelete($query)
{
$deletionHours = config('shop.cart.deletion_hours', 24);
return $query->where('status', '!=', CartStatus::CONVERTED->value)
->whereNull('converted_at')
->where(function ($q) use ($deletionHours) {
$q->where('last_activity_at', '<', now()->subHours($deletionHours))
->orWhere(function ($q2) use ($deletionHours) {
$q2->whereNull('last_activity_at')
->where('updated_at', '<', now()->subHours($deletionHours));
});
});
}
/**
* Scope to get carts with expired status.
*/
public function scopeWithExpiredStatus($query)
{
return $query->where('status', CartStatus::EXPIRED->value);
}
public function customer(): MorphTo
{
return $this->morphTo();
@ -792,25 +924,11 @@ class Cart extends Model
->sum('total_amount');
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function isConverted(): bool
{
return !is_null($this->converted_at);
}
public function scopeActive($query)
{
return $query->whereNull('converted_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeForUser($query, $userOrId)
{
if (is_object($userOrId)) {
@ -1252,6 +1370,9 @@ class Cart extends Model
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
}
// Touch activity timestamp
$this->touchActivity();
return $cartItem;
}
@ -1315,6 +1436,9 @@ class Cart extends Model
}
}
// Touch activity timestamp
$this->touchActivity();
return $item ?? true;
}

View File

@ -596,6 +596,212 @@ class Order extends Model
return $query->whereBetween('created_at', [$from, $until]);
}
/**
* Scope to filter by payment provider.
*/
public function scopeByPaymentProvider($query, string $provider)
{
return $query->where('payment_provider', $provider);
}
/**
* Scope to filter by payment method.
*/
public function scopeByPaymentMethod($query, string $method)
{
return $query->where('payment_method', $method);
}
/**
* Scope to get orders with refunds.
*/
public function scopeWithRefunds($query)
{
return $query->where('amount_refunded', '>', 0);
}
/**
* Scope to get fully refunded orders.
*/
public function scopeFullyRefunded($query)
{
return $query->whereColumn('amount_refunded', '>=', 'amount_paid');
}
/**
* Scope to get orders created today.
*/
public function scopeToday($query)
{
return $query->whereDate('created_at', now()->toDateString());
}
/**
* Scope to get orders created this week.
*/
public function scopeThisWeek($query)
{
return $query->whereBetween('created_at', [
now()->startOfWeek(),
now()->endOfWeek(),
]);
}
/**
* Scope to get orders created this month.
*/
public function scopeThisMonth($query)
{
return $query->whereBetween('created_at', [
now()->startOfMonth(),
now()->endOfMonth(),
]);
}
/**
* Scope to get orders created this year.
*/
public function scopeThisYear($query)
{
return $query->whereBetween('created_at', [
now()->startOfYear(),
now()->endOfYear(),
]);
}
// =========================================================================
// STATIC SUMMARY METHODS
// =========================================================================
/**
* Get total revenue (sum of amount_paid) across all orders.
* Returns value in cents.
*/
public static function getTotalRevenue(): int
{
return (int) static::sum('amount_paid');
}
/**
* Get total revenue for a date range.
* Returns value in cents.
*/
public static function getRevenueBetween(\DateTimeInterface $from, \DateTimeInterface $until): int
{
return (int) static::createdBetween($from, $until)->sum('amount_paid');
}
/**
* Get total refunded amount across all orders.
* Returns value in cents.
*/
public static function getTotalRefunded(): int
{
return (int) static::sum('amount_refunded');
}
/**
* Get net revenue (revenue minus refunds).
* Returns value in cents.
*/
public static function getNetRevenue(): int
{
return static::getTotalRevenue() - static::getTotalRefunded();
}
/**
* Get average order value.
* Returns value in cents.
*/
public static function getAverageOrderValue(): float
{
return (float) (static::avg('amount_total') ?? 0);
}
/**
* Get order counts by status.
*/
public static function getCountsByStatus(): array
{
$counts = static::selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status')
->toArray();
// Initialize all statuses with 0
$result = [];
foreach (OrderStatus::cases() as $status) {
$result[$status->value] = $counts[$status->value] ?? 0;
}
return $result;
}
/**
* Get revenue summary for a specific period.
*/
public static function getRevenueSummary(\DateTimeInterface $from, \DateTimeInterface $until): array
{
$query = static::createdBetween($from, $until);
return [
'period' => [
'from' => $from->format('Y-m-d H:i:s'),
'until' => $until->format('Y-m-d H:i:s'),
],
'orders' => [
'total' => $query->count(),
'completed' => (clone $query)->completed()->count(),
'paid' => (clone $query)->paid()->count(),
'unpaid' => (clone $query)->unpaid()->count(),
],
'revenue' => [
'gross' => (int) (clone $query)->sum('amount_total'),
'paid' => (int) (clone $query)->sum('amount_paid'),
'refunded' => (int) (clone $query)->sum('amount_refunded'),
'net' => (int) ((clone $query)->sum('amount_paid') - (clone $query)->sum('amount_refunded')),
],
'averages' => [
'order_value' => (float) (clone $query)->avg('amount_total') ?? 0,
'paid_amount' => (float) (clone $query)->avg('amount_paid') ?? 0,
],
];
}
/**
* Get daily revenue breakdown for a date range.
*/
public static function getDailyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection
{
return static::createdBetween($from, $until)
->selectRaw('DATE(created_at) as date')
->selectRaw('COUNT(*) as order_count')
->selectRaw('SUM(amount_total) as total_amount')
->selectRaw('SUM(amount_paid) as paid_amount')
->selectRaw('SUM(amount_refunded) as refunded_amount')
->groupBy('date')
->orderBy('date')
->get();
}
/**
* Get monthly revenue breakdown for a date range.
*/
public static function getMonthlyRevenue(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection
{
return static::createdBetween($from, $until)
->selectRaw('YEAR(created_at) as year')
->selectRaw('MONTH(created_at) as month')
->selectRaw('COUNT(*) as order_count')
->selectRaw('SUM(amount_total) as total_amount')
->selectRaw('SUM(amount_paid) as paid_amount')
->selectRaw('SUM(amount_refunded) as refunded_amount')
->groupBy('year', 'month')
->orderBy('year')
->orderBy('month')
->get();
}
// =========================================================================
// FACTORY METHODS
// =========================================================================

View File

@ -198,6 +198,144 @@ class Product extends Model implements Purchasable, Cartable
return true;
}
/**
* Duplicate/clone this product with all related data.
*
* Creates a copy of the product including:
* - All basic attributes (with modified slug/sku)
* - All prices
* - All categories
* - All product attributes
* - All product relations (related, upsell, cross-sell)
* - All children (variants) if includeChildren is true
*
* @param array $overrides Attributes to override in the duplicated product
* @param bool $includeChildren Whether to duplicate child products (variants)
* @param bool $includePrices Whether to duplicate prices
* @param bool $includeCategories Whether to duplicate category associations
* @param bool $includeAttributes Whether to duplicate product attributes
* @param bool $includeRelations Whether to duplicate product relations
* @return static The duplicated product
*/
public function duplicate(
array $overrides = [],
bool $includeChildren = true,
bool $includePrices = true,
bool $includeCategories = true,
bool $includeAttributes = true,
bool $includeRelations = true
): static {
// Get attributes to duplicate
$attributes = $this->attributesToArray();
// Remove fields that shouldn't be copied
unset(
$attributes['id'],
$attributes['created_at'],
$attributes['updated_at'],
$attributes['deleted_at'],
$attributes['stripe_product_id'], // Stripe ID should be unique
);
// Generate unique slug and SKU
$baseSlug = preg_replace('/-copy(-\d+)?$/', '', $this->slug);
$suffix = '-copy';
$counter = 1;
while (static::where('slug', $baseSlug . $suffix)->exists()) {
$suffix = '-copy-' . ++$counter;
}
$attributes['slug'] = $baseSlug . $suffix;
// Handle SKU uniqueness
if ($this->sku) {
$baseSku = preg_replace('/-COPY(-\d+)?$/i', '', $this->sku);
$skuSuffix = '-COPY';
$skuCounter = 1;
while (static::where('sku', $baseSku . $skuSuffix)->exists()) {
$skuSuffix = '-COPY-' . ++$skuCounter;
}
$attributes['sku'] = $baseSku . $skuSuffix;
}
// Set as draft by default
$attributes['status'] = ProductStatus::DRAFT->value;
$attributes['published_at'] = null;
// Apply overrides
$attributes = array_merge($attributes, $overrides);
// Create the duplicate product
$duplicate = static::create($attributes);
// Duplicate prices
if ($includePrices && method_exists($this, 'prices')) {
foreach ($this->prices as $price) {
$priceData = $price->attributesToArray();
unset(
$priceData['id'],
$priceData['purchasable_id'],
$priceData['purchasable_type'],
$priceData['stripe_price_id'],
$priceData['created_at'],
$priceData['updated_at']
);
$duplicate->prices()->create($priceData);
}
}
// Duplicate categories
if ($includeCategories && method_exists($this, 'categories')) {
$categoryIds = $this->categories->pluck('id')->toArray();
if (!empty($categoryIds)) {
$duplicate->categories()->sync($categoryIds);
}
}
// Duplicate attributes (product attributes, not model attributes)
if ($includeAttributes) {
foreach ($this->attributes()->get() as $attribute) {
$attrData = $attribute->attributesToArray();
unset(
$attrData['id'],
$attrData['product_id'],
$attrData['created_at'],
$attrData['updated_at']
);
$duplicate->attributes()->create($attrData);
}
}
// Duplicate product relations
if ($includeRelations && method_exists($this, 'relatedProducts')) {
foreach ($this->relatedProducts as $related) {
$duplicate->relatedProducts()->attach($related->id, [
'type' => $related->pivot->type ?? 'related',
'sort_order' => $related->pivot->sort_order ?? 0,
]);
}
}
// Duplicate children (variants)
if ($includeChildren) {
foreach ($this->children as $child) {
$child->duplicate(
['parent_id' => $duplicate->id],
false, // Don't recurse into children's children
$includePrices,
$includeCategories,
$includeAttributes,
$includeRelations
);
}
}
return $duplicate->fresh();
}
public static function getAvailableActions(): array
{
return ProductAction::getAvailableActions();

View File

@ -2,13 +2,22 @@
namespace Blax\Shop\Services;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class ShopService
{
// =========================================================================
// PRODUCT QUERIES
// =========================================================================
/**
* Get all products query builder
*
@ -127,6 +136,414 @@ class ShopService
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.
*/
public function stats(): array
{
return [
'products' => [
'total' => Product::count(),
'published' => Product::where('status', 'published')->count(),
'draft' => Product::where('status', 'draft')->count(),
'featured' => Product::where('featured', true)->count(),
],
'orders' => [
'total' => Order::count(),
'pending' => $this->pendingOrders()->count(),
'processing' => $this->processingOrders()->count(),
'completed' => $this->completedOrders()->count(),
'cancelled' => $this->cancelledOrders()->count(),
'today' => $this->ordersToday()->count(),
'this_week' => $this->ordersThisWeek()->count(),
'this_month' => $this->ordersThisMonth()->count(),
],
'revenue' => [
'total' => $this->totalRevenue(),
'today' => $this->revenueToday(),
'this_week' => $this->revenueThisWeek(),
'this_month' => $this->revenueThisMonth(),
'this_year' => $this->revenueThisYear(),
'refunded' => $this->totalRefunded(),
'net' => $this->netRevenue(),
'average_order' => $this->averageOrderValue(),
],
'carts' => [
'active' => Cart::where('status', 'active')->count(),
'abandoned' => Cart::where('status', 'abandoned')->count(),
'expired' => Cart::where('status', 'expired')->count(),
'converted' => Cart::whereNotNull('converted_at')->count(),
],
'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();
}
// =========================================================================
// 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
{
$carts = $this->cartsToDelete()->get();
$count = $carts->count();
foreach ($carts as $cart) {
$cart->forceDelete();
}
return $count;
}
// =========================================================================
// CONFIGURATION HELPERS
// =========================================================================
/**
* Get shop configuration value
*
@ -148,4 +565,15 @@ class ShopService
{
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);
}
}

View File

@ -2,7 +2,9 @@
namespace Blax\Shop;
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
use Blax\Shop\Console\Commands\ShopReinstallCommand;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
class ShopServiceProvider extends ServiceProvider
@ -53,6 +55,7 @@ class ShopServiceProvider extends ServiceProvider
if ($this->app->runningInConsole()) {
$this->commands([
ShopReinstallCommand::class,
ShopCleanupCartsCommand::class,
\Blax\Shop\Console\Commands\ReleaseExpiredStocks::class,
\Blax\Shop\Console\Commands\ShopListProductsCommand::class,
\Blax\Shop\Console\Commands\ShopToggleActionCommand::class,
@ -63,6 +66,24 @@ class ShopServiceProvider extends ServiceProvider
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
]);
}
// Register scheduled tasks
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
// Cleanup carts every hour if auto_cleanup is enabled
if (config('shop.cart.auto_cleanup', true)) {
$schedule->command('shop:cleanup-carts', ['--force'])
->hourly()
->withoutOverlapping()
->runInBackground();
}
// Release expired stocks every 5 minutes
if (config('shop.stock.auto_release_expired', true)) {
$schedule->command('shop:release-expired-stocks')
->everyFiveMinutes()
->withoutOverlapping();
}
});
}
/**

View File

@ -0,0 +1,306 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class CartExpirationTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function cart_sets_last_activity_at_on_creation()
{
$cart = Cart::factory()->create();
$this->assertNotNull($cart->last_activity_at);
}
#[Test]
public function adding_to_cart_updates_last_activity_at()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
// Create cart with old activity timestamp
$cart = Cart::factory()->create([
'customer_id' => $user->id,
'customer_type' => get_class($user),
'last_activity_at' => now()->subHours(2),
]);
$oldActivityAt = $cart->last_activity_at;
// Add item to cart
$cart->addToCart($product);
$this->assertTrue($cart->fresh()->last_activity_at->gt($oldActivityAt));
}
#[Test]
public function removing_from_cart_updates_last_activity_at()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
$cart = Cart::factory()->create([
'customer_id' => $user->id,
'customer_type' => get_class($user),
]);
$cart->addToCart($product);
// Set old activity timestamp
$cart->update(['last_activity_at' => now()->subHours(2)]);
$oldActivityAt = $cart->fresh()->last_activity_at;
// Remove item from cart
$cart->removeFromCart($product);
$this->assertTrue($cart->fresh()->last_activity_at->gt($oldActivityAt));
}
#[Test]
public function touch_activity_updates_timestamp()
{
$cart = Cart::factory()->create([
'last_activity_at' => now()->subHours(2),
]);
$oldActivityAt = $cart->last_activity_at;
$cart->touchActivity();
$this->assertTrue($cart->last_activity_at->gt($oldActivityAt));
}
#[Test]
public function cart_is_expired_after_configured_time()
{
config(['shop.cart.expiration_minutes' => 60]);
$cart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subMinutes(61),
]);
$this->assertTrue($cart->isExpired());
}
#[Test]
public function cart_is_not_expired_within_configured_time()
{
config(['shop.cart.expiration_minutes' => 60]);
$cart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subMinutes(30),
]);
$this->assertFalse($cart->isExpired());
}
#[Test]
public function cart_with_expired_status_is_expired()
{
$cart = Cart::factory()->create([
'status' => CartStatus::EXPIRED,
'last_activity_at' => now(), // Recent activity doesn't matter
]);
$this->assertTrue($cart->isExpired());
}
#[Test]
public function cart_should_be_deleted_after_configured_time()
{
config(['shop.cart.deletion_hours' => 24]);
$cart = Cart::factory()->create([
'status' => CartStatus::ABANDONED,
'last_activity_at' => now()->subHours(25),
'converted_at' => null,
]);
$this->assertTrue($cart->shouldBeDeleted());
}
#[Test]
public function cart_should_not_be_deleted_within_configured_time()
{
config(['shop.cart.deletion_hours' => 24]);
$cart = Cart::factory()->create([
'status' => CartStatus::ABANDONED,
'last_activity_at' => now()->subHours(12),
'converted_at' => null,
]);
$this->assertFalse($cart->shouldBeDeleted());
}
#[Test]
public function converted_cart_should_never_be_deleted()
{
config(['shop.cart.deletion_hours' => 24]);
$cart = Cart::factory()->create([
'status' => CartStatus::CONVERTED,
'last_activity_at' => now()->subDays(30),
'converted_at' => now()->subDays(30),
]);
$this->assertFalse($cart->shouldBeDeleted());
}
#[Test]
public function mark_as_expired_changes_status()
{
$cart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
]);
$cart->markAsExpired();
$this->assertEquals(CartStatus::EXPIRED, $cart->status);
}
#[Test]
public function mark_as_abandoned_changes_status()
{
$cart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
]);
$cart->markAsAbandoned();
$this->assertEquals(CartStatus::ABANDONED, $cart->status);
}
#[Test]
public function scope_should_expire_returns_correct_carts()
{
config(['shop.cart.expiration_minutes' => 60]);
// Should expire - old activity
$expiredCart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subHours(2),
]);
// Should not expire - recent activity
$activeCart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subMinutes(30),
]);
// Should not expire - already expired
$alreadyExpiredCart = Cart::factory()->create([
'status' => CartStatus::EXPIRED,
'last_activity_at' => now()->subHours(2),
]);
$cartsToExpire = Cart::shouldExpire()->get();
$this->assertCount(1, $cartsToExpire);
$this->assertEquals($expiredCart->id, $cartsToExpire->first()->id);
}
#[Test]
public function scope_should_delete_returns_correct_carts()
{
config(['shop.cart.deletion_hours' => 24]);
// Should delete - old and not converted
$oldCart = Cart::factory()->create([
'status' => CartStatus::ABANDONED,
'last_activity_at' => now()->subDays(2),
'converted_at' => null,
]);
// Should not delete - recent
$recentCart = Cart::factory()->create([
'status' => CartStatus::ABANDONED,
'last_activity_at' => now()->subHours(12),
'converted_at' => null,
]);
// Should not delete - converted
$convertedCart = Cart::factory()->create([
'status' => CartStatus::CONVERTED,
'last_activity_at' => now()->subDays(2),
'converted_at' => now()->subDays(2),
]);
$cartsToDelete = Cart::shouldDelete()->get();
$this->assertCount(1, $cartsToDelete);
$this->assertEquals($oldCart->id, $cartsToDelete->first()->id);
}
#[Test]
public function carts_can_check_if_converted()
{
$unconvertedCart = Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'converted_at' => null,
'expires_at' => null,
]);
$convertedCart = Cart::factory()->create([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$this->assertFalse($unconvertedCart->isConverted());
$this->assertTrue($convertedCart->isConverted());
}
#[Test]
public function scope_expired_returns_carts_with_expired_status()
{
Cart::factory()->create(['status' => CartStatus::ACTIVE]);
Cart::factory()->create(['status' => CartStatus::EXPIRED]);
$expiredCarts = Cart::withExpiredStatus()->get();
$this->assertCount(1, $expiredCarts);
}
#[Test]
public function scope_abandoned_returns_inactive_carts()
{
config(['shop.cart.expiration_minutes' => 60]);
// Should be considered abandoned - active but old
Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subHours(2),
]);
// Should not be considered abandoned - recent activity
Cart::factory()->create([
'status' => CartStatus::ACTIVE,
'last_activity_at' => now()->subMinutes(30),
]);
$abandonedCarts = Cart::abandoned(60)->get();
$this->assertCount(1, $abandonedCarts);
}
#[Test]
public function is_converted_method_returns_true_for_converted_carts()
{
$convertedCart = Cart::factory()->create(['converted_at' => now()]);
$unconvertedCart = Cart::factory()->create(['converted_at' => null]);
$this->assertTrue($convertedCart->isConverted());
$this->assertFalse($unconvertedCart->isConverted());
}
}

View File

@ -0,0 +1,293 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class OrderSummaryTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function order_can_get_total_revenue()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$user->addToCart($product);
$cart2 = $user->checkoutCart();
$cart2->order->recordPayment(5000, 'ref2', 'stripe', 'stripe');
$this->assertEquals(10000, Order::getTotalRevenue());
}
#[Test]
public function order_can_get_revenue_between_dates()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 100.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(10000, 'ref1', 'stripe', 'stripe');
$revenue = Order::getRevenueBetween(
now()->subDay(),
now()->addDay()
);
$this->assertEquals(10000, $revenue);
}
#[Test]
public function order_can_get_total_refunded()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$cart->order->recordRefund(2000, 'Partial refund');
$this->assertEquals(2000, Order::getTotalRefunded());
}
#[Test]
public function order_can_get_net_revenue()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$cart->order->recordRefund(1000, 'Partial refund');
$this->assertEquals(4000, Order::getNetRevenue());
}
#[Test]
public function order_can_get_average_order_value()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$user->addToCart($product);
$user->checkoutCart();
// Both orders have 5000 cents total
$this->assertEquals(5000.0, Order::getAverageOrderValue());
}
#[Test]
public function order_can_get_counts_by_status()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
// Create pending order
$user->addToCart($product);
$user->checkoutCart();
// Create processing order
$user->addToCart($product);
$cart2 = $user->checkoutCart();
$cart2->order->markAsProcessing();
// Create completed order
$user->addToCart($product);
$cart3 = $user->checkoutCart();
$cart3->order->forceStatus(OrderStatus::COMPLETED);
$counts = Order::getCountsByStatus();
$this->assertEquals(1, $counts['pending']);
$this->assertEquals(1, $counts['processing']);
$this->assertEquals(1, $counts['completed']);
}
#[Test]
public function order_can_get_revenue_summary()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$cart->order->recordRefund(1000, 'Partial refund');
$summary = Order::getRevenueSummary(now()->subDay(), now()->addDay());
$this->assertArrayHasKey('period', $summary);
$this->assertArrayHasKey('orders', $summary);
$this->assertArrayHasKey('revenue', $summary);
$this->assertArrayHasKey('averages', $summary);
$this->assertEquals(1, $summary['orders']['total']);
$this->assertEquals(5000, $summary['revenue']['paid']);
$this->assertEquals(1000, $summary['revenue']['refunded']);
$this->assertEquals(4000, $summary['revenue']['net']);
}
#[Test]
public function order_can_get_daily_revenue()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$dailyRevenue = Order::getDailyRevenue(now()->subDays(7), now()->addDay());
$this->assertCount(1, $dailyRevenue);
$this->assertEquals(1, $dailyRevenue->first()->order_count);
$this->assertEquals(5000, $dailyRevenue->first()->paid_amount);
}
#[Test]
public function order_scope_today_returns_todays_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Order::today()->get());
}
#[Test]
public function order_scope_this_week_returns_this_weeks_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Order::thisWeek()->get());
}
#[Test]
public function order_scope_this_month_returns_this_months_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Order::thisMonth()->get());
}
#[Test]
public function order_scope_by_payment_provider_returns_filtered_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(2500, 'ref1', 'card', 'stripe');
$user->addToCart($product);
$cart2 = $user->checkoutCart();
$cart2->order->recordPayment(2500, 'ref2', 'bank_transfer', 'paypal');
$stripeOrders = Order::byPaymentProvider('stripe')->get();
$paypalOrders = Order::byPaymentProvider('paypal')->get();
$this->assertCount(1, $stripeOrders);
$this->assertCount(1, $paypalOrders);
}
#[Test]
public function order_scope_with_refunds_returns_orders_with_refunds()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
// Order with refund
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$cart->order->recordRefund(1000, 'Partial refund');
// Order without refund
$user->addToCart($product);
$cart2 = $user->checkoutCart();
$cart2->order->recordPayment(5000, 'ref2', 'stripe', 'stripe');
$ordersWithRefunds = Order::withRefunds()->get();
$this->assertCount(1, $ordersWithRefunds);
}
#[Test]
public function order_scope_fully_refunded_returns_fully_refunded_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
// Fully refunded order
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref1', 'stripe', 'stripe');
$cart->order->recordRefund(5000, 'Full refund');
// Partially refunded order
$user->addToCart($product);
$cart2 = $user->checkoutCart();
$cart2->order->recordPayment(5000, 'ref2', 'stripe', 'stripe');
$cart2->order->recordRefund(1000, 'Partial refund');
$fullyRefundedOrders = Order::fullyRefunded()->get();
$this->assertCount(1, $fullyRefundedOrders);
}
}

View File

@ -0,0 +1,226 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
class ProductDuplicateTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function product_can_be_duplicated()
{
$product = Product::factory()->create([
'name' => 'Original Product',
'slug' => 'original-product',
]);
$duplicate = $product->duplicate();
$this->assertNotEquals($product->id, $duplicate->id);
$this->assertEquals('Original Product', $duplicate->name);
$this->assertEquals('original-product-copy', $duplicate->slug);
$this->assertEquals(ProductStatus::DRAFT, $duplicate->status);
}
#[Test]
public function duplicate_generates_unique_slug()
{
$product = Product::factory()->create([
'slug' => 'test-product',
]);
$duplicate1 = $product->duplicate();
$duplicate2 = $product->duplicate();
$this->assertEquals('test-product-copy', $duplicate1->slug);
$this->assertEquals('test-product-copy-2', $duplicate2->slug);
}
#[Test]
public function duplicate_generates_unique_sku()
{
$product = Product::factory()->create([
'sku' => 'SKU-001',
]);
$duplicate1 = $product->duplicate();
$duplicate2 = $product->duplicate();
$this->assertEquals('SKU-001-COPY', $duplicate1->sku);
$this->assertEquals('SKU-001-COPY-2', $duplicate2->sku);
}
#[Test]
public function duplicate_includes_prices()
{
$product = Product::factory()->withPrices(unit_amount: 25.00)->create();
$duplicate = $product->duplicate();
$this->assertCount(1, $duplicate->prices);
// Factory stores price in dollars, so 25.00 stays as 25.00 (in cents it would be 2500)
$this->assertEquals($product->prices->first()->unit_amount, $duplicate->prices->first()->unit_amount);
}
#[Test]
public function duplicate_can_exclude_prices()
{
$product = Product::factory()->withPrices(unit_amount: 25.00)->create();
$duplicate = $product->duplicate(includePrices: false);
$this->assertCount(0, $duplicate->prices);
}
#[Test]
public function duplicate_includes_categories()
{
$product = Product::factory()->create();
$category = ProductCategory::factory()->create();
$product->categories()->attach($category);
$duplicate = $product->duplicate();
$this->assertCount(1, $duplicate->categories);
$this->assertEquals($category->id, $duplicate->categories->first()->id);
}
#[Test]
public function duplicate_can_exclude_categories()
{
$product = Product::factory()->create();
$category = ProductCategory::factory()->create();
$product->categories()->attach($category);
$duplicate = $product->duplicate(includeCategories: false);
$this->assertCount(0, $duplicate->categories);
}
#[Test]
public function duplicate_includes_attributes()
{
$product = Product::factory()->create();
$product->attributes()->create([
'key' => 'color',
'value' => 'red',
'type' => 'text',
]);
$duplicate = $product->duplicate();
$this->assertCount(1, $duplicate->attributes);
$this->assertEquals('color', $duplicate->attributes->first()->key);
$this->assertEquals('red', $duplicate->attributes->first()->value);
}
#[Test]
public function duplicate_can_exclude_attributes()
{
$product = Product::factory()->create();
$product->attributes()->create([
'key' => 'color',
'value' => 'red',
'type' => 'text',
]);
$duplicate = $product->duplicate(includeAttributes: false);
$this->assertCount(0, $duplicate->attributes);
}
#[Test]
public function duplicate_can_override_attributes()
{
$product = Product::factory()->create([
'name' => 'Original Name',
]);
$duplicate = $product->duplicate([
'name' => 'New Name',
]);
$this->assertEquals('New Name', $duplicate->name);
}
#[Test]
public function duplicate_does_not_copy_stripe_product_id()
{
$product = Product::factory()->create([
'stripe_product_id' => 'prod_abc123',
]);
$duplicate = $product->duplicate();
$this->assertNull($duplicate->stripe_product_id);
}
#[Test]
public function duplicate_sets_status_to_draft()
{
$product = Product::factory()->create([
'status' => ProductStatus::PUBLISHED,
]);
$duplicate = $product->duplicate();
$this->assertEquals(ProductStatus::DRAFT, $duplicate->status);
$this->assertNull($duplicate->published_at);
}
#[Test]
public function duplicate_includes_children()
{
$parent = Product::factory()->create([
'slug' => 'parent-product',
]);
$child = Product::factory()->create([
'parent_id' => $parent->id,
'slug' => 'child-variant',
]);
$duplicate = $parent->duplicate();
$this->assertCount(1, $duplicate->children);
$this->assertEquals($duplicate->id, $duplicate->children->first()->parent_id);
}
#[Test]
public function duplicate_can_exclude_children()
{
$parent = Product::factory()->create();
$child = Product::factory()->create([
'parent_id' => $parent->id,
]);
$duplicate = $parent->duplicate(includeChildren: false);
$this->assertCount(0, $duplicate->children);
}
#[Test]
public function duplicate_does_not_copy_stripe_price_id()
{
$product = Product::factory()->create();
$product->prices()->create([
'name' => 'Default Price',
'unit_amount' => 2500,
'currency' => 'USD',
'is_default' => true,
'stripe_price_id' => 'price_abc123',
]);
$duplicate = $product->duplicate();
$this->assertNull($duplicate->prices->first()->stripe_price_id);
}
}

View File

@ -0,0 +1,274 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Facades\Shop;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class ShopServiceTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function shop_facade_can_get_products()
{
Product::factory()->count(3)->create();
$this->assertCount(3, Shop::products()->get());
}
#[Test]
public function shop_facade_can_get_single_product()
{
$product = Product::factory()->create();
$found = Shop::product($product->id);
$this->assertNotNull($found);
$this->assertEquals($product->id, $found->id);
}
#[Test]
public function shop_facade_can_get_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Shop::orders()->get());
}
#[Test]
public function shop_facade_can_get_order_by_number()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$order = $cart->order;
$found = Shop::orderByNumber($order->order_number);
$this->assertNotNull($found);
$this->assertEquals($order->id, $found->id);
}
#[Test]
public function shop_facade_can_get_orders_today()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Shop::ordersToday()->get());
}
#[Test]
public function shop_facade_can_get_orders_this_week()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Shop::ordersThisWeek()->get());
}
#[Test]
public function shop_facade_can_get_orders_this_month()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Shop::ordersThisMonth()->get());
}
#[Test]
public function shop_facade_can_get_pending_orders()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 25.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$user->checkoutCart();
$this->assertCount(1, Shop::pendingOrders()->get());
}
#[Test]
public function shop_facade_can_calculate_total_revenue()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref123', 'stripe', 'stripe');
$this->assertEquals(5000, Shop::totalRevenue());
}
#[Test]
public function shop_facade_can_calculate_revenue_today()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 100.00)->create([
'manage_stock' => false,
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(10000, 'ref123', 'stripe', 'stripe');
$this->assertEquals(10000, Shop::revenueToday());
}
#[Test]
public function shop_facade_can_get_stats()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices(unit_amount: 50.00)->create([
'manage_stock' => false,
'status' => 'published',
]);
$user->addToCart($product);
$cart = $user->checkoutCart();
$cart->order->recordPayment(5000, 'ref123', 'stripe', 'stripe');
$stats = Shop::stats();
$this->assertArrayHasKey('products', $stats);
$this->assertArrayHasKey('orders', $stats);
$this->assertArrayHasKey('revenue', $stats);
$this->assertArrayHasKey('carts', $stats);
$this->assertEquals(1, $stats['products']['total']);
$this->assertEquals(1, $stats['orders']['total']);
$this->assertEquals(5000, $stats['revenue']['total']);
}
#[Test]
public function shop_facade_can_format_money()
{
$formatted = Shop::formatMoney(12345);
$this->assertEquals('123.45 USD', $formatted);
}
#[Test]
public function shop_facade_can_format_money_with_custom_currency()
{
$formatted = Shop::formatMoney(9999, 'EUR');
$this->assertEquals('99.99 EUR', $formatted);
}
#[Test]
public function shop_facade_can_get_top_products()
{
$product1 = Product::factory()->withPrices()->create(['manage_stock' => false]);
$product2 = Product::factory()->withPrices()->create(['manage_stock' => false]);
$user = User::factory()->create();
// Buy product1 three times
for ($i = 0; $i < 3; $i++) {
$user->addToCart($product1);
$user->checkoutCart();
}
// Buy product2 once
$user->addToCart($product2);
$user->checkoutCart();
$topProducts = Shop::topProducts(2);
$this->assertCount(2, $topProducts);
$this->assertEquals($product1->id, $topProducts->first()->id);
}
#[Test]
public function shop_facade_can_get_active_carts()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
$user->addToCart($product);
$this->assertCount(1, Shop::activeCarts()->get());
}
#[Test]
public function shop_facade_can_expire_stale_carts()
{
$cart = Cart::factory()->create([
'status' => 'active',
'last_activity_at' => now()->subHours(2), // 2 hours ago
]);
$expiredCount = Shop::expireStaleCarts();
$this->assertEquals(1, $expiredCount);
$this->assertEquals('expired', $cart->fresh()->status->value);
}
#[Test]
public function shop_facade_can_delete_old_carts()
{
$cart = Cart::factory()->create([
'status' => 'abandoned',
'last_activity_at' => now()->subDays(2), // 2 days ago
'converted_at' => null,
]);
$deletedCount = Shop::deleteOldCarts();
$this->assertEquals(1, $deletedCount);
$this->assertNull(Cart::find($cart->id));
}
#[Test]
public function shop_facade_does_not_delete_converted_carts()
{
$cart = Cart::factory()->create([
'status' => 'converted',
'last_activity_at' => now()->subDays(2), // 2 days ago
'converted_at' => now()->subDay(),
]);
$deletedCount = Shop::deleteOldCarts();
$this->assertEquals(0, $deletedCount);
$this->assertNotNull(Cart::find($cart->id));
}
}