From 6e9c9043ae4bbb47f69f0dc217aa8262002a6f42 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 29 Dec 2025 10:26:51 +0100 Subject: [PATCH] I order, A handy methods --- config/shop.php | 6 + .../Commands/ShopCleanupCartsCommand.php | 132 ++++++ src/Facades/Shop.php | 49 ++ src/Models/Cart.php | 152 ++++++- src/Models/Order.php | 206 +++++++++ src/Models/Product.php | 138 ++++++ src/Services/ShopService.php | 428 ++++++++++++++++++ src/ShopServiceProvider.php | 21 + tests/Unit/CartExpirationTest.php | 306 +++++++++++++ tests/Unit/OrderSummaryTest.php | 293 ++++++++++++ tests/Unit/ProductDuplicateTest.php | 226 +++++++++ tests/Unit/ShopServiceTest.php | 274 +++++++++++ 12 files changed, 2217 insertions(+), 14 deletions(-) create mode 100644 src/Console/Commands/ShopCleanupCartsCommand.php create mode 100644 tests/Unit/CartExpirationTest.php create mode 100644 tests/Unit/OrderSummaryTest.php create mode 100644 tests/Unit/ProductDuplicateTest.php create mode 100644 tests/Unit/ShopServiceTest.php diff --git a/config/shop.php b/config/shop.php index 6414e04..88ce798 100644 --- a/config/shop.php +++ b/config/shop.php @@ -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 diff --git a/src/Console/Commands/ShopCleanupCartsCommand.php b/src/Console/Commands/ShopCleanupCartsCommand.php new file mode 100644 index 0000000..2e5cdaa --- /dev/null +++ b/src/Console/Commands/ShopCleanupCartsCommand.php @@ -0,0 +1,132 @@ +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; + } +} diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index fc78148..336b7fa 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -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 { diff --git a/src/Models/Cart.php b/src/Models/Cart.php index a384a21..6daf3ad 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -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; } diff --git a/src/Models/Order.php b/src/Models/Order.php index b6f8a47..6e42ad6 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -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 // ========================================================================= diff --git a/src/Models/Product.php b/src/Models/Product.php index 06487c2..5ee5d42 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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(); diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 82275c1..f1eb32a 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -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); + } } diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index 94385a3..72cb3fc 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -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(); + } + }); } /** diff --git a/tests/Unit/CartExpirationTest.php b/tests/Unit/CartExpirationTest.php new file mode 100644 index 0000000..719b71e --- /dev/null +++ b/tests/Unit/CartExpirationTest.php @@ -0,0 +1,306 @@ +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()); + } +} diff --git a/tests/Unit/OrderSummaryTest.php b/tests/Unit/OrderSummaryTest.php new file mode 100644 index 0000000..e4b9da0 --- /dev/null +++ b/tests/Unit/OrderSummaryTest.php @@ -0,0 +1,293 @@ +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); + } +} diff --git a/tests/Unit/ProductDuplicateTest.php b/tests/Unit/ProductDuplicateTest.php new file mode 100644 index 0000000..7ec4c2b --- /dev/null +++ b/tests/Unit/ProductDuplicateTest.php @@ -0,0 +1,226 @@ +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); + } +} diff --git a/tests/Unit/ShopServiceTest.php b/tests/Unit/ShopServiceTest.php new file mode 100644 index 0000000..6339932 --- /dev/null +++ b/tests/Unit/ShopServiceTest.php @@ -0,0 +1,274 @@ +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)); + } +}