I order, A handy methods
This commit is contained in:
parent
9c1fcd6cfd
commit
6e9c9043ae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue