I order, A handy methods
This commit is contained in:
parent
9c1fcd6cfd
commit
6e9c9043ae
|
|
@ -113,6 +113,12 @@ return [
|
||||||
'expire_after_days' => 30,
|
'expire_after_days' => 30,
|
||||||
'auto_cleanup' => true,
|
'auto_cleanup' => true,
|
||||||
'merge_on_login' => 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
|
// 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;
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Shop Facade - Admin and Developer helper methods.
|
||||||
|
*
|
||||||
|
* Product Queries:
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder products()
|
* @method static \Illuminate\Database\Eloquent\Builder products()
|
||||||
* @method static \Blax\Shop\Models\Product|null product(mixed $id)
|
* @method static \Blax\Shop\Models\Product|null product(mixed $id)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder categories()
|
* @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 bool checkStock(\Blax\Shop\Models\Product $product, int $quantity)
|
||||||
* @method static int getAvailableStock(\Blax\Shop\Models\Product $product)
|
* @method static int getAvailableStock(\Blax\Shop\Models\Product $product)
|
||||||
* @method static bool isOnSale(\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 mixed config(string $key, mixed $default = null)
|
||||||
* @method static string currency()
|
* @method static string currency()
|
||||||
|
* @method static string formatMoney(int $cents, ?string $currency = null)
|
||||||
*/
|
*/
|
||||||
class Shop extends Facade
|
class Shop extends Facade
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,143 @@ class Cart extends Model
|
||||||
|
|
||||||
protected static function booted()
|
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) {
|
static::deleting(function ($cart) {
|
||||||
$cart->items()->delete();
|
$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
|
public function customer(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
|
|
@ -792,25 +924,11 @@ class Cart extends Model
|
||||||
->sum('total_amount');
|
->sum('total_amount');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isExpired(): bool
|
|
||||||
{
|
|
||||||
return $this->expires_at && $this->expires_at->isPast();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isConverted(): bool
|
public function isConverted(): bool
|
||||||
{
|
{
|
||||||
return !is_null($this->converted_at);
|
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)
|
public function scopeForUser($query, $userOrId)
|
||||||
{
|
{
|
||||||
if (is_object($userOrId)) {
|
if (is_object($userOrId)) {
|
||||||
|
|
@ -1252,6 +1370,9 @@ class Cart extends Model
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
|
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Touch activity timestamp
|
||||||
|
$this->touchActivity();
|
||||||
|
|
||||||
return $cartItem;
|
return $cartItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1315,6 +1436,9 @@ class Cart extends Model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Touch activity timestamp
|
||||||
|
$this->touchActivity();
|
||||||
|
|
||||||
return $item ?? true;
|
return $item ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,212 @@ class Order extends Model
|
||||||
return $query->whereBetween('created_at', [$from, $until]);
|
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
|
// FACTORY METHODS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,144 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
return true;
|
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
|
public static function getAvailableActions(): array
|
||||||
{
|
{
|
||||||
return ProductAction::getAvailableActions();
|
return ProductAction::getAvailableActions();
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,22 @@
|
||||||
|
|
||||||
namespace Blax\Shop\Services;
|
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\Product;
|
||||||
use Blax\Shop\Models\ProductCategory;
|
use Blax\Shop\Models\ProductCategory;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class ShopService
|
class ShopService
|
||||||
{
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// PRODUCT QUERIES
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all products query builder
|
* Get all products query builder
|
||||||
*
|
*
|
||||||
|
|
@ -127,6 +136,414 @@ class ShopService
|
||||||
return $product->isOnSale();
|
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
|
* Get shop configuration value
|
||||||
*
|
*
|
||||||
|
|
@ -148,4 +565,15 @@ class ShopService
|
||||||
{
|
{
|
||||||
return config('shop.currency', 'USD');
|
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;
|
namespace Blax\Shop;
|
||||||
|
|
||||||
|
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
|
||||||
use Blax\Shop\Console\Commands\ShopReinstallCommand;
|
use Blax\Shop\Console\Commands\ShopReinstallCommand;
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class ShopServiceProvider extends ServiceProvider
|
class ShopServiceProvider extends ServiceProvider
|
||||||
|
|
@ -53,6 +55,7 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
ShopReinstallCommand::class,
|
ShopReinstallCommand::class,
|
||||||
|
ShopCleanupCartsCommand::class,
|
||||||
\Blax\Shop\Console\Commands\ReleaseExpiredStocks::class,
|
\Blax\Shop\Console\Commands\ReleaseExpiredStocks::class,
|
||||||
\Blax\Shop\Console\Commands\ShopListProductsCommand::class,
|
\Blax\Shop\Console\Commands\ShopListProductsCommand::class,
|
||||||
\Blax\Shop\Console\Commands\ShopToggleActionCommand::class,
|
\Blax\Shop\Console\Commands\ShopToggleActionCommand::class,
|
||||||
|
|
@ -63,6 +66,24 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
|
\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