From f55c7e11dfd3b5872af5da16fe2a0d1d0c177bf8 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sun, 17 May 2026 11:24:43 +0200 Subject: [PATCH] A events, IA commands - Introduced events for stock management including StockBecameLow, StockClaimed, StockClaimExpired, StockDecreased, StockDepleted, StockIncreased, StockReleased, StockReplenished, StockFullyAvailable, and StockFullyAvailable. - Added events for Stripe payment processing: StripePaymentFailed, StripePaymentSucceeded, StripePriceSynced, StripeProductSynced, StripeRefundProcessed, and StripeWebhookReceived. - Created tests for command availability, listing, stocks, and event dispatching to ensure proper functionality and integration. --- .../Commands/ShopAvailabilityCommand.php | 335 ++++++++++++++++++ src/Console/Commands/ShopListCartsCommand.php | 67 ++++ .../Commands/ShopListCategoriesCommand.php | 55 +++ src/Console/Commands/ShopListCommand.php | 47 +++ .../Commands/ShopListOrdersCommand.php | 52 +++ .../Commands/ShopListProductsCommand.php | 2 +- .../Commands/ShopListPurchasesCommand.php | 2 +- .../Commands/ShopStocksClaimsCommand.php | 123 +++++++ src/Console/Commands/ShopStocksCommand.php | 200 +++++++++++ src/Events/BookingCancelled.php | 22 ++ src/Events/BookingConfirmed.php | 22 ++ src/Events/CartAbandoned.php | 23 ++ src/Events/CartConverted.php | 26 ++ src/Events/CartCreated.php | 22 ++ src/Events/CartExpired.php | 21 ++ src/Events/CartItemAdded.php | 25 ++ src/Events/CartItemRemoved.php | 26 ++ src/Events/CartItemUpdated.php | 25 ++ src/Events/OrderCancelled.php | 22 ++ src/Events/OrderCreated.php | 22 ++ src/Events/OrderFulfilled.php | 22 ++ src/Events/OrderPaid.php | 21 ++ src/Events/OrderRefunded.php | 25 ++ src/Events/ProductDeleted.php | 21 ++ src/Events/ProductPublished.php | 21 ++ src/Events/ProductUnpublished.php | 21 ++ src/Events/PurchaseCreated.php | 25 ++ src/Events/PurchaseRefunded.php | 25 ++ src/Events/StockBecameLow.php | 27 ++ src/Events/StockClaimExpired.php | 26 ++ src/Events/StockClaimed.php | 26 ++ src/Events/StockDecreased.php | 26 ++ src/Events/StockDepleted.php | 22 ++ src/Events/StockFullyAvailable.php | 29 ++ src/Events/StockIncreased.php | 26 ++ src/Events/StockReleased.php | 26 ++ src/Events/StockReplenished.php | 25 ++ src/Events/StripePaymentFailed.php | 28 ++ src/Events/StripePaymentSucceeded.php | 29 ++ src/Events/StripePriceSynced.php | 24 ++ src/Events/StripeProductSynced.php | 25 ++ src/Events/StripeRefundProcessed.php | 29 ++ src/Events/StripeWebhookReceived.php | 30 ++ src/Models/Cart.php | 4 + src/Models/Order.php | 4 + src/Models/Product.php | 28 ++ src/Models/ProductPurchase.php | 4 + src/Models/ProductStock.php | 12 +- src/ShopServiceProvider.php | 9 +- src/Traits/HasStocks.php | 61 +++- tests/Feature/CommandAvailabilityTest.php | 220 ++++++++++++ tests/Feature/CommandListTest.php | 86 +++++ tests/Feature/CommandStocksTest.php | 170 +++++++++ tests/Feature/EventsWiredUpTest.php | 271 ++++++++++++++ 54 files changed, 2578 insertions(+), 9 deletions(-) create mode 100644 src/Console/Commands/ShopAvailabilityCommand.php create mode 100644 src/Console/Commands/ShopListCartsCommand.php create mode 100644 src/Console/Commands/ShopListCategoriesCommand.php create mode 100644 src/Console/Commands/ShopListCommand.php create mode 100644 src/Console/Commands/ShopListOrdersCommand.php create mode 100644 src/Console/Commands/ShopStocksClaimsCommand.php create mode 100644 src/Console/Commands/ShopStocksCommand.php create mode 100644 src/Events/BookingCancelled.php create mode 100644 src/Events/BookingConfirmed.php create mode 100644 src/Events/CartAbandoned.php create mode 100644 src/Events/CartConverted.php create mode 100644 src/Events/CartCreated.php create mode 100644 src/Events/CartExpired.php create mode 100644 src/Events/CartItemAdded.php create mode 100644 src/Events/CartItemRemoved.php create mode 100644 src/Events/CartItemUpdated.php create mode 100644 src/Events/OrderCancelled.php create mode 100644 src/Events/OrderCreated.php create mode 100644 src/Events/OrderFulfilled.php create mode 100644 src/Events/OrderPaid.php create mode 100644 src/Events/OrderRefunded.php create mode 100644 src/Events/ProductDeleted.php create mode 100644 src/Events/ProductPublished.php create mode 100644 src/Events/ProductUnpublished.php create mode 100644 src/Events/PurchaseCreated.php create mode 100644 src/Events/PurchaseRefunded.php create mode 100644 src/Events/StockBecameLow.php create mode 100644 src/Events/StockClaimExpired.php create mode 100644 src/Events/StockClaimed.php create mode 100644 src/Events/StockDecreased.php create mode 100644 src/Events/StockDepleted.php create mode 100644 src/Events/StockFullyAvailable.php create mode 100644 src/Events/StockIncreased.php create mode 100644 src/Events/StockReleased.php create mode 100644 src/Events/StockReplenished.php create mode 100644 src/Events/StripePaymentFailed.php create mode 100644 src/Events/StripePaymentSucceeded.php create mode 100644 src/Events/StripePriceSynced.php create mode 100644 src/Events/StripeProductSynced.php create mode 100644 src/Events/StripeRefundProcessed.php create mode 100644 src/Events/StripeWebhookReceived.php create mode 100644 tests/Feature/CommandAvailabilityTest.php create mode 100644 tests/Feature/CommandListTest.php create mode 100644 tests/Feature/CommandStocksTest.php create mode 100644 tests/Feature/EventsWiredUpTest.php diff --git a/src/Console/Commands/ShopAvailabilityCommand.php b/src/Console/Commands/ShopAvailabilityCommand.php new file mode 100644 index 0000000..efce9b2 --- /dev/null +++ b/src/Console/Commands/ShopAvailabilityCommand.php @@ -0,0 +1,335 @@ +resolveProduct((string) $this->argument('product')); + if (! $product) { + $this->error("No product matched '{$this->argument('product')}'."); + return self::FAILURE; + } + + if ($this->option('day')) { + return $this->renderDayDetail($product, Carbon::parse((string) $this->option('day'))->startOfDay()); + } + + $rangeStart = $this->option('from') + ? Carbon::parse((string) $this->option('from'))->startOfDay() + : Carbon::now()->startOfMonth(); + $rangeEnd = $this->option('to') + ? Carbon::parse((string) $this->option('to'))->endOfDay() + : $rangeStart->copy()->endOfMonth(); + + // Snap to a Mon→Sun grid so weeks line up. + $gridStart = $rangeStart->copy()->startOfWeek(Carbon::MONDAY); + $gridEnd = $rangeEnd->copy()->endOfWeek(Carbon::SUNDAY); + + $calendar = $product->calendarAvailability($gridStart, $gridEnd); + + $this->renderProductHeader($product); + $this->renderSummaryCounters($product); + $this->renderMonthLabel($rangeStart); + $this->renderLegend(); + $this->renderCalendarGrid($calendar['dates'], $gridStart, $gridEnd, $rangeStart); + $this->renderFooterStats($calendar); + + return self::SUCCESS; + } + + private function resolveProduct(string $identifier): ?Product + { + $model = config('shop.models.product', Product::class); + + return $model::query() + ->where('id', $identifier) + ->orWhere('slug', $identifier) + ->orWhere('sku', $identifier) + ->orWhere('name', 'like', "%{$identifier}%") + ->first(); + } + + private function renderProductHeader(Product $product): void + { + $type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—'); + $sku = $product->sku ?: '—'; + + $this->newLine(); + $this->line(' '.$product->name.''); + $this->line(' type: '.$type.' sku: '.$sku.' id: '.$product->id); + $this->newLine(); + } + + private function renderSummaryCounters(Product $product): void + { + $available = $product->getAvailableStock(); + $currentClaims = $product->getCurrentlyClaimedStock(); + $futureClaims = $product->getFutureClaimedStock(); + $activeAndPlanned = $product->getActiveAndPlannedClaimedStock(); + + $this->line(sprintf( + ' Available %s Currently claimed %d Future claims %d Active & planned %d', + $this->infinityOr($available), + $currentClaims, + $futureClaims, + $activeAndPlanned, + )); + $this->newLine(); + } + + private function renderMonthLabel(Carbon $focus): void + { + $this->line(' '.$focus->format('F Y').''); + $this->newLine(); + } + + private function renderLegend(): void + { + $this->line( + ' ━━━━━ Full availability '. + '━━━━━ Partial '. + '━━━━━ No stock' + ); + $this->newLine(); + } + + /** + * @param array $days + */ + private function renderCalendarGrid(array $days, Carbon $gridStart, Carbon $gridEnd, Carbon $focus): void + { + $w = self::CELL_WIDTH; + $hr = '┌'.implode('┬', array_fill(0, 7, str_repeat('─', $w))).'┐'; + $midRule = '├'.implode('┼', array_fill(0, 7, str_repeat('─', $w))).'┤'; + $bot = '└'.implode('┴', array_fill(0, 7, str_repeat('─', $w))).'┘'; + + $this->line(' '.$hr); + $this->line(' │'.collect(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']) + ->map(fn ($h) => $this->pad($h, $w))->implode('│').'│'); + $this->line(' '.$midRule); + + $cursor = $gridStart->copy(); + $weeks = []; + while ($cursor <= $gridEnd) { + $week = []; + for ($i = 0; $i < 7; $i++) { + $week[] = $cursor->copy(); + $cursor->addDay(); + } + $weeks[] = $week; + } + + foreach ($weeks as $i => $week) { + $dayLine = '│'; + $barLine = '│'; + $statLine = '│'; + + foreach ($week as $day) { + $key = $day->toDateString(); + $cell = $days[$key] ?? ['min' => 0, 'max' => 0]; + $status = $this->statusFor($cell); + $color = $this->colorFor($status); + $inMonth = $day->month === $focus->month; + $isToday = $day->isToday(); + + $numText = (string) $day->day; + if ($isToday) { + $numText = '['.$numText.']'; + } + $numCell = $this->pad($numText, $w); + if ($isToday) { + $numCell = "$numCell"; + } elseif (! $inMonth) { + $numCell = "$numCell"; + } + $dayLine .= $numCell.'│'; + + $bar = str_repeat('━', $w - 2); + $barCell = ' '."$bar".' '; + $barLine .= $barCell.'│'; + + $stat = $this->infinityOr($cell['min']).'-'.$this->infinityOr($cell['max']); + $statCell = $this->pad($stat, $w); + if (! $inMonth) { + $statCell = "$statCell"; + } else { + $statCell = "$statCell"; + } + $statLine .= $statCell.'│'; + } + + $this->line(' '.$dayLine); + $this->line(' '.$barLine); + $this->line(' '.$statLine); + $this->line(' '.($i === count($weeks) - 1 ? $bot : $midRule)); + } + $this->newLine(); + } + + /** + * @param array{max_available: int, min_available: int, dates: array} $calendar + */ + private function renderFooterStats(array $calendar): void + { + $days = $calendar['dates']; + $maxAvailable = $this->infinityOr($calendar['max_available']); + $minAvailable = $this->infinityOr($calendar['min_available']); + $daysTracked = (string) count($days); + $lowStockDays = (string) count(array_filter( + $days, + fn (array $d) => $d['min'] === 0 || $d['max'] === 0, + )); + + $boxes = [ + ['MAX AVAILABLE', $maxAvailable, 'cyan'], + ['MIN AVAILABLE', $minAvailable, 'red'], + ['DAYS TRACKED', $daysTracked, 'gray'], + ['LOW STOCK DAYS', $lowStockDays, 'yellow'], + ]; + + $boxWidth = 16; + $top = '┌'.implode('┬', array_fill(0, count($boxes), str_repeat('─', $boxWidth))).'┐'; + $bot = '└'.implode('┴', array_fill(0, count($boxes), str_repeat('─', $boxWidth))).'┘'; + + $labelLine = '│'; + $valueLine = '│'; + foreach ($boxes as [$label, $value, $color]) { + $labelLine .= "".$this->pad($label, $boxWidth).'│'; + $valueLine .= "".$this->pad($value, $boxWidth).'│'; + } + + $this->line(' '.$top); + $this->line(' '.$labelLine); + $this->line(' '.$valueLine); + $this->line(' '.$bot); + $this->newLine(); + } + + private function renderDayDetail(Product $product, Carbon $day): int + { + $timeline = $product->dayAvailability($day); + + $this->renderProductHeader($product); + + $this->line(' '.$day->format('l, F j, Y').''); + $this->newLine(); + + if ($timeline === PHP_INT_MAX) { + $this->line(' Unlimited availability all day. (manage_stock = false)'); + $this->newLine(); + return self::SUCCESS; + } + + // $timeline is array + $this->line(' Stock changes throughout the day:'); + $this->newLine(); + + $rows = []; + $previous = null; + foreach ($timeline as $time => $available) { + // Skip redundant rows where nothing actually changed since the last event. + if ($previous !== null && $available === $previous) { + continue; + } + $previous = $available; + $rows[] = [$time, $available]; + } + + $timeWidth = 8; + $availWidth = 14; + $noteWidth = 22; + $top = '┌'.str_repeat('─', $timeWidth).'┬'.str_repeat('─', $availWidth).'┬'.str_repeat('─', $noteWidth).'┐'; + $mid = '├'.str_repeat('─', $timeWidth).'┼'.str_repeat('─', $availWidth).'┼'.str_repeat('─', $noteWidth).'┤'; + $bot = '└'.str_repeat('─', $timeWidth).'┴'.str_repeat('─', $availWidth).'┴'.str_repeat('─', $noteWidth).'┘'; + + $this->line(' '.$top); + $this->line(' │'.$this->pad('TIME', $timeWidth).'│'.$this->pad('AVAILABLE', $availWidth).'│'.$this->pad('NOTE', $noteWidth).'│'); + $this->line(' '.$mid); + + foreach ($rows as [$time, $available]) { + $unitWord = $available === 1 ? 'unit' : 'units'; + $availText = $available.' '.$unitWord; + $note = $available === 0 ? '⚠ Out of stock' : ''; + $color = $available === 0 ? 'red' : 'green'; + + $timeCell = $this->pad($time, $timeWidth); + $availCell = "".$this->pad($availText, $availWidth).''; + $noteCell = "".$this->pad($note, $noteWidth).''; + + $this->line(' │'.$timeCell.'│'.$availCell.'│'.$noteCell.'│'); + } + + $this->line(' '.$bot); + $this->newLine(); + + $values = array_values($timeline); + $this->line(sprintf( + ' MIN STOCK %d MAX STOCK %d EVENTS %d', + min($values), + max($values), + count($rows), + )); + $this->newLine(); + + return self::SUCCESS; + } + + /** + * @param array{min: int, max: int} $cell + */ + private function statusFor(array $cell): string + { + if ($cell['max'] <= 0) { + return 'none'; + } + if ($cell['min'] <= 0) { + return 'partial'; + } + return 'full'; + } + + private function colorFor(string $status): string + { + return match ($status) { + 'full' => 'green', + 'partial' => 'yellow', + default => 'red', + }; + } + + private function infinityOr(int $value): string + { + return $value === PHP_INT_MAX ? '∞' : (string) $value; + } + + private function pad(string $value, int $width): string + { + $len = mb_strlen($value); + if ($len >= $width) { + return mb_substr($value, 0, $width); + } + $extra = $width - $len; + $left = (int) floor($extra / 2); + $right = $extra - $left; + + return str_repeat(' ', $left).$value.str_repeat(' ', $right); + } +} diff --git a/src/Console/Commands/ShopListCartsCommand.php b/src/Console/Commands/ShopListCartsCommand.php new file mode 100644 index 0000000..cec03cd --- /dev/null +++ b/src/Console/Commands/ShopListCartsCommand.php @@ -0,0 +1,67 @@ +latest(); + + if ($this->option('guest')) { + $query->whereNull('customer_id'); + } + if ($this->option('with-items')) { + $query->withCount('items'); + } + + $limit = max(1, (int) $this->option('limit')); + $carts = $query->limit($limit)->get(); + + if ($carts->isEmpty()) { + $this->info('No carts found.'); + return self::SUCCESS; + } + + $headers = ['ID', 'Customer', 'Session', 'Status', 'Last Activity']; + if ($this->option('with-items')) { + $headers[] = 'Items'; + } + + $rows = $carts->map(function ($cart) { + $customer = $cart->customer_id + ? class_basename((string) $cart->customer_type).'#'.substr((string) $cart->customer_id, 0, 8) + : ''; + $status = $cart->status instanceof \BackedEnum ? $cart->status->value : (string) ($cart->status ?? '—'); + + $row = [ + substr((string) $cart->id, 0, 8).'…', + $customer, + $cart->session_id ? substr((string) $cart->session_id, 0, 12).'…' : '—', + $status, + $cart->last_activity_at?->format('Y-m-d H:i') ?? '—', + ]; + if ($this->option('with-items')) { + $row[] = (int) ($cart->items_count ?? 0); + } + return $row; + }); + + $this->table($headers, $rows); + $this->info("Showing {$carts->count()} cart(s)"); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/ShopListCategoriesCommand.php b/src/Console/Commands/ShopListCategoriesCommand.php new file mode 100644 index 0000000..4bdaaf5 --- /dev/null +++ b/src/Console/Commands/ShopListCategoriesCommand.php @@ -0,0 +1,55 @@ +orderBy('name'); + + if ($this->option('with-products')) { + $query->withCount('products'); + } + + $categories = $query->get(); + + if ($categories->isEmpty()) { + $this->info('No categories found.'); + return self::SUCCESS; + } + + $headers = ['ID', 'Name', 'Slug', 'Parent']; + if ($this->option('with-products')) { + $headers[] = 'Products'; + } + + $rows = $categories->map(function ($cat) { + $row = [ + $cat->id, + $cat->name, + $cat->slug ?? '—', + $cat->parent_id ? substr((string) $cat->parent_id, 0, 8).'…' : '—', + ]; + if ($this->option('with-products')) { + $row[] = (int) ($cat->products_count ?? 0); + } + return $row; + }); + + $this->table($headers, $rows); + $this->info("Total categories: {$categories->count()}"); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/ShopListCommand.php b/src/Console/Commands/ShopListCommand.php new file mode 100644 index 0000000..697a83d --- /dev/null +++ b/src/Console/Commands/ShopListCommand.php @@ -0,0 +1,47 @@ + command only + * requires extending this map (and registering the new command). + */ + private const LISTABLES = [ + 'products' => 'product', + 'purchases' => 'product_purchase', + 'categories' => 'product_category', + 'orders' => 'order', + 'carts' => 'cart', + ]; + + public function handle(): int + { + $this->newLine(); + $this->line(' Shop listings'); + $this->newLine(); + + $rows = []; + foreach (self::LISTABLES as $suffix => $modelKey) { + $modelClass = config("shop.models.{$modelKey}"); + $count = $modelClass ? (int) $modelClass::query()->count() : 0; + $rows[] = ["shop:list:{$suffix}", number_format($count).' entries']; + } + + $this->table(['Command', 'Total'], $rows); + $this->line(' Run any of the above to see the full table. Most accept filter options — ` --help` shows what.'); + $this->newLine(); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/ShopListOrdersCommand.php b/src/Console/Commands/ShopListOrdersCommand.php new file mode 100644 index 0000000..c6646bf --- /dev/null +++ b/src/Console/Commands/ShopListOrdersCommand.php @@ -0,0 +1,52 @@ +latest(); + + if ($user = $this->option('user')) { + $query->where('user_id', $user); + } + if ($status = $this->option('status')) { + $query->where('status', $status); + } + + $limit = max(1, (int) $this->option('limit')); + $orders = $query->limit($limit)->get(); + + if ($orders->isEmpty()) { + $this->info('No orders found.'); + return self::SUCCESS; + } + + $rows = $orders->map(fn ($order) => [ + substr((string) $order->id, 0, 8).'…', + $order->user_id ? substr((string) $order->user_id, 0, 8).'…' : '—', + $order->status instanceof \BackedEnum ? $order->status->value : (string) ($order->status ?? '—'), + $order->total ?? '—', + $order->currency ?? '—', + $order->created_at?->format('Y-m-d H:i') ?? '—', + ]); + + $this->table(['ID', 'User', 'Status', 'Total', 'Currency', 'Created'], $rows); + $this->info("Showing {$orders->count()} order(s)"); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/ShopListProductsCommand.php b/src/Console/Commands/ShopListProductsCommand.php index bc75c0d..f0a1081 100644 --- a/src/Console/Commands/ShopListProductsCommand.php +++ b/src/Console/Commands/ShopListProductsCommand.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class ShopListProductsCommand extends Command { - protected $signature = 'shop:list-products + protected $signature = 'shop:list:products {--with-actions : Include action counts} {--with-purchases : Include purchase counts} {--enabled : Only show enabled products} diff --git a/src/Console/Commands/ShopListPurchasesCommand.php b/src/Console/Commands/ShopListPurchasesCommand.php index 219394e..b841bf3 100644 --- a/src/Console/Commands/ShopListPurchasesCommand.php +++ b/src/Console/Commands/ShopListPurchasesCommand.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class ShopListPurchasesCommand extends Command { - protected $signature = 'shop:list-purchases + protected $signature = 'shop:list:purchases {product? : Product ID to filter by} {--user= : Filter by user ID} {--status= : Filter by status} diff --git a/src/Console/Commands/ShopStocksClaimsCommand.php b/src/Console/Commands/ShopStocksClaimsCommand.php new file mode 100644 index 0000000..5049ff2 --- /dev/null +++ b/src/Console/Commands/ShopStocksClaimsCommand.php @@ -0,0 +1,123 @@ +option('limit')); + $onlyActive = (bool) $this->option('active'); + $now = Carbon::now(); + + $query = ProductStock::query() + ->withoutGlobalScope('willExpire') + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value); + + if ($identifier = $this->argument('product')) { + $product = $this->resolveProduct((string) $identifier); + if (! $product) { + $this->error("No product matched '{$identifier}'."); + return self::FAILURE; + } + $query->where('product_id', $product->getKey()); + $this->newLine(); + $this->line(' '.$product->name.' ('.($product->sku ?: $product->id).')'); + } else { + $this->newLine(); + $this->line(' All pending claims across the catalogue'); + } + + if ($onlyActive) { + $query->where(function ($q) use ($now) { + $q->whereNull('claimed_from')->orWhere('claimed_from', '<=', $now); + })->where(function ($q) use ($now) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', $now); + }); + $this->line(' Filter: currently active only'); + } + + $claims = $query + ->orderBy('claimed_from') + ->orderBy('expires_at') + ->limit($limit) + ->get(); + + $this->newLine(); + + if ($claims->isEmpty()) { + $this->line(' (no pending claims found)'); + $this->newLine(); + return self::SUCCESS; + } + + $rows = $claims->map(function (ProductStock $stock) use ($now): array { + $state = $this->classify($stock, $now); + + return [ + $stock->product?->name ? $this->truncate($stock->product->name, 22) : (string) $stock->product_id, + (int) abs((int) $stock->quantity), + $stock->claimed_from?->format('Y-m-d H:i') ?? 'immediate', + $stock->expires_at?->format('Y-m-d H:i') ?? 'no expiry', + $state, + $stock->reference_type ? class_basename($stock->reference_type).'#'.substr((string) $stock->reference_id, 0, 8) : '—', + $this->truncate((string) ($stock->note ?? ''), 28), + ]; + })->all(); + + $this->table( + ['Product', 'Qty', 'Claim From', 'Expires', 'State', 'Reference', 'Note'], + $rows, + ); + + $this->line(' Showing '.$claims->count().' claim'.($claims->count() === 1 ? '' : 's').' (limit '.$limit.').'); + $this->newLine(); + + return self::SUCCESS; + } + + private function classify(ProductStock $stock, Carbon $now): string + { + if ($stock->claimed_from && $stock->claimed_from > $now) { + return 'upcoming'; + } + if ($stock->expires_at && $stock->expires_at <= $now) { + return 'expired'; + } + return 'active'; + } + + private function resolveProduct(string $identifier): ?Product + { + $model = config('shop.models.product', Product::class); + + return $model::query() + ->where('id', $identifier) + ->orWhere('slug', $identifier) + ->orWhere('sku', $identifier) + ->orWhere('name', 'like', "%{$identifier}%") + ->first(); + } + + private function truncate(string $value, int $max): string + { + return mb_strlen($value) > $max ? mb_substr($value, 0, $max - 1).'…' : $value; + } +} diff --git a/src/Console/Commands/ShopStocksCommand.php b/src/Console/Commands/ShopStocksCommand.php new file mode 100644 index 0000000..022b53f --- /dev/null +++ b/src/Console/Commands/ShopStocksCommand.php @@ -0,0 +1,200 @@ +argument('product'); + + return $identifier + ? $this->renderProductDetail((string) $identifier, max(1, (int) $this->option('limit'))) + : $this->renderOverview(); + } + + private function renderOverview(): int + { + $productModel = config('shop.models.product', Product::class); + $products = $productModel::query()->orderBy('name')->get(); + + if ($products->isEmpty()) { + $this->info('No products found.'); + return self::SUCCESS; + } + + $rows = $products->map(function (Product $product): array { + $assigned = $product->manage_stock ? (int) $product->getMaxStocksAttribute() : null; + $used = $product->manage_stock ? $this->totalUsed($product) : null; + $available = $product->getAvailableStock(); + $claimed = $product->getCurrentlyClaimedStock(); + $type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—'); + + return [ + 'id' => substr((string) $product->id, 0, 8).'…', + 'name' => $this->truncate((string) $product->name, 30), + 'type' => $type, + 'assigned' => $assigned === null ? '∞' : (string) $assigned, + 'used' => $used === null ? '—' : (string) $used, + 'available' => $available === PHP_INT_MAX ? '∞' : (string) $available, + 'claimed' => (string) $claimed, + ]; + })->all(); + + $this->newLine(); + $this->table( + ['ID', 'Name', 'Type', 'Assigned', 'Used', 'Available', 'Claimed'], + $rows, + ); + $this->line(' Total products: '.$products->count().' '. + 'Run shop:stocks {product} for a detailed report.'); + $this->newLine(); + + return self::SUCCESS; + } + + private function renderProductDetail(string $identifier, int $limit): int + { + $product = $this->resolveProduct($identifier); + if (! $product) { + $this->error("No product matched '{$identifier}'."); + return self::FAILURE; + } + + $type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—'); + $sku = $product->sku ?: '—'; + + $this->newLine(); + $this->line(' '.$product->name.''); + $this->line(' type: '.$type.' sku: '.$sku.' id: '.$product->id); + $this->newLine(); + + if (! $product->manage_stock) { + $this->line(' Stock management is OFF. (unlimited availability)'); + $this->newLine(); + return self::SUCCESS; + } + + $assigned = (int) $product->getMaxStocksAttribute(); + $used = $this->totalUsed($product); + $available = $product->getAvailableStock(); + $currentClaims = $product->getCurrentlyClaimedStock(); + $futureClaims = $product->getFutureClaimedStock(); + $activeAndPlanned = $product->getActiveAndPlannedClaimedStock(); + + $this->renderTotalsBox([ + ['ASSIGNED', $assigned, 'cyan'], + ['USED', $used, 'gray'], + ['AVAILABLE', $available, $available > 0 ? 'green' : 'red'], + ['CLAIMED NOW', $currentClaims, $currentClaims > 0 ? 'yellow' : 'gray'], + ['CLAIMED LATER', $futureClaims, $futureClaims > 0 ? 'blue' : 'gray'], + ['ACTIVE+PLANNED', $activeAndPlanned, 'magenta'], + ]); + + $this->line(' Recent stock ledger (newest first, capped at '.$limit.' entries):'); + $this->newLine(); + + $ledger = $product->stocks() + ->withoutGlobalScope('willExpire') + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + + if ($ledger->isEmpty()) { + $this->line(' (no ledger entries yet)'); + $this->newLine(); + return self::SUCCESS; + } + + $this->table( + ['When', 'Type', 'Status', 'Qty', 'Claim From', 'Expires', 'Note'], + $ledger->map(fn ($s) => [ + $s->created_at?->format('Y-m-d H:i') ?? '—', + $s->type instanceof \BackedEnum ? $s->type->value : (string) $s->type, + $s->status instanceof \BackedEnum ? $s->status->value : (string) $s->status, + (int) $s->quantity, + $s->claimed_from?->format('Y-m-d H:i') ?? '—', + $s->expires_at?->format('Y-m-d H:i') ?? '—', + $this->truncate((string) ($s->note ?? ''), 30), + ])->all(), + ); + + return self::SUCCESS; + } + + private function totalUsed(Product $product): int + { + return (int) abs( + (int) $product->stocks() + ->withoutGlobalScope('willExpire') + ->where('type', StockType::DECREASE->value) + ->where('status', StockStatus::COMPLETED->value) + ->sum('quantity') + ); + } + + /** + * @param list $boxes + */ + private function renderTotalsBox(array $boxes): void + { + $boxWidth = 16; + $rule = fn (string $l, string $j, string $r) => $l.implode($j, array_fill(0, count($boxes), str_repeat('─', $boxWidth))).$r; + + $labelLine = '│'; + $valueLine = '│'; + foreach ($boxes as [$label, $value, $color]) { + $display = $value === PHP_INT_MAX ? '∞' : (string) $value; + $labelLine .= "".$this->pad($label, $boxWidth).'│'; + $valueLine .= "".$this->pad($display, $boxWidth).'│'; + } + + $this->line(' '.$rule('┌', '┬', '┐')); + $this->line(' '.$labelLine); + $this->line(' '.$valueLine); + $this->line(' '.$rule('└', '┴', '┘')); + $this->newLine(); + } + + private function resolveProduct(string $identifier): ?Product + { + $model = config('shop.models.product', Product::class); + + return $model::query() + ->where('id', $identifier) + ->orWhere('slug', $identifier) + ->orWhere('sku', $identifier) + ->orWhere('name', 'like', "%{$identifier}%") + ->first(); + } + + private function pad(string $value, int $width): string + { + $len = mb_strlen($value); + if ($len >= $width) { + return mb_substr($value, 0, $width); + } + $extra = $width - $len; + $left = (int) floor($extra / 2); + + return str_repeat(' ', $left).$value.str_repeat(' ', $extra - $left); + } + + private function truncate(string $value, int $max): string + { + return mb_strlen($value) > $max ? mb_substr($value, 0, $max - 1).'…' : $value; + } +} diff --git a/src/Events/BookingCancelled.php b/src/Events/BookingCancelled.php new file mode 100644 index 0000000..08feee8 --- /dev/null +++ b/src/Events/BookingCancelled.php @@ -0,0 +1,22 @@ + $payload + */ + public function __construct( + public ?Order $order, + public array $payload, + public ?string $reason = null, + ) {} +} diff --git a/src/Events/StripePaymentSucceeded.php b/src/Events/StripePaymentSucceeded.php new file mode 100644 index 0000000..d0eeaa8 --- /dev/null +++ b/src/Events/StripePaymentSucceeded.php @@ -0,0 +1,29 @@ + $payload + */ + public function __construct( + public ?Order $order, + public array $payload, + ) {} +} diff --git a/src/Events/StripePriceSynced.php b/src/Events/StripePriceSynced.php new file mode 100644 index 0000000..448d268 --- /dev/null +++ b/src/Events/StripePriceSynced.php @@ -0,0 +1,24 @@ + $payload + */ + public function __construct( + public ?Order $order, + public float $amount, + public array $payload, + ) {} +} diff --git a/src/Events/StripeWebhookReceived.php b/src/Events/StripeWebhookReceived.php new file mode 100644 index 0000000..2f710a7 --- /dev/null +++ b/src/Events/StripeWebhookReceived.php @@ -0,0 +1,30 @@ + $payload + */ + public function __construct( + public string $type, + public array $payload, + ) {} +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 0854a20..94b9714 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -63,6 +63,10 @@ class Cart extends Model 'is_ready_to_checkout', ]; + protected $dispatchesEvents = [ + 'created' => \Blax\Shop\Events\CartCreated::class, + ]; + public function __construct(array $attributes = []) { parent::__construct($attributes); diff --git a/src/Models/Order.php b/src/Models/Order.php index 9492447..1c2fb5b 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -88,6 +88,10 @@ class Order extends Model 'is_fully_paid', ]; + protected $dispatchesEvents = [ + 'created' => \Blax\Shop\Events\OrderCreated::class, + ]; + public function __construct(array $attributes = []) { parent::__construct($attributes); diff --git a/src/Models/Product.php b/src/Models/Product.php index ce31354..ff71367 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -7,6 +7,9 @@ namespace Blax\Shop\Models; use Blax\Shop\Contracts\Cartable; use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Shop\Events\ProductCreated; +use Blax\Shop\Events\ProductDeleted; +use Blax\Shop\Events\ProductPublished; +use Blax\Shop\Events\ProductUnpublished; use Blax\Shop\Events\ProductUpdated; use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Enums\ProductStatus; @@ -138,6 +141,7 @@ class Product extends Model implements Purchasable, Cartable protected $dispatchesEvents = [ 'created' => ProductCreated::class, 'updated' => ProductUpdated::class, + 'deleted' => ProductDeleted::class, ]; protected $hidden = [ @@ -189,6 +193,30 @@ class Product extends Model implements Purchasable, Cartable if (config('shop.cache.enabled')) { Cache::forget(config('shop.cache.prefix') . 'product:' . $model->id); } + + // ProductPublished / ProductUnpublished fire on the transition, + // not on every save. The PUBLISHED status is the public surface, + // so leaving it (to DRAFT, ARCHIVED, etc.) is the "unpublish" edge. + if ($model->wasChanged('status')) { + $before = $model->getOriginal('status'); + $after = $model->status; + $publishedEnum = ProductStatus::PUBLISHED; + $wasPublished = $before === $publishedEnum || (is_string($before) && $before === $publishedEnum->value); + $isPublished = $after === $publishedEnum; + if (! $wasPublished && $isPublished) { + event(new ProductPublished($model)); + } elseif ($wasPublished && ! $isPublished) { + event(new ProductUnpublished($model)); + } + } + }); + + // Fire ProductPublished on initial creation when the row lands in the + // published state (the `updated` hook above only catches transitions). + static::created(function ($model) { + if ($model->status === ProductStatus::PUBLISHED) { + event(new ProductPublished($model)); + } }); static::deleted(function ($model) { diff --git a/src/Models/ProductPurchase.php b/src/Models/ProductPurchase.php index a88b08f..a7add48 100644 --- a/src/Models/ProductPurchase.php +++ b/src/Models/ProductPurchase.php @@ -84,6 +84,10 @@ class ProductPurchase extends Model 'meta' => 'object', ]; + protected $dispatchesEvents = [ + 'created' => \Blax\Shop\Events\PurchaseCreated::class, + ]; + public function __construct(array $attributes = []) { parent::__construct($attributes); diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index 1d60630..a4dd80c 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -213,13 +213,13 @@ class ProductStock extends Model * * @return bool True if released successfully, false if not pending */ - public function release(): bool + public function release(bool $expired = false): bool { if ($this->status !== StockStatus::PENDING) { return false; } - return DB::transaction(function () { + return DB::transaction(function () use ($expired) { // Mark claim as completed (released) $this->status = StockStatus::COMPLETED; $this->save(); @@ -228,6 +228,12 @@ class ProductStock extends Model // This creates a RETURN entry to offset the DECREASE that was created when claiming $this->product->increaseStock($this->quantity, StockType::RETURN); + if ($expired) { + event(new \Blax\Shop\Events\StockClaimExpired($this->product, $this)); + } else { + event(new \Blax\Shop\Events\StockReleased($this->product, $this)); + } + return true; }); } @@ -302,7 +308,7 @@ class ProductStock extends Model $count = 0; foreach ($expired as $stock) { - if ($stock->release()) { + if ($stock->release(expired: true)) { $count++; } } diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index dca5262..e8aa66c 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -47,11 +47,18 @@ class ShopServiceProvider extends ServiceProvider ShopReinstallCommand::class, ShopCleanupCartsCommand::class, \Blax\Shop\Console\Commands\ReleaseExpiredStocks::class, + \Blax\Shop\Console\Commands\ShopListCommand::class, \Blax\Shop\Console\Commands\ShopListProductsCommand::class, + \Blax\Shop\Console\Commands\ShopListPurchasesCommand::class, + \Blax\Shop\Console\Commands\ShopListCategoriesCommand::class, + \Blax\Shop\Console\Commands\ShopListOrdersCommand::class, + \Blax\Shop\Console\Commands\ShopListCartsCommand::class, \Blax\Shop\Console\Commands\ShopToggleActionCommand::class, \Blax\Shop\Console\Commands\ShopTestActionCommand::class, - \Blax\Shop\Console\Commands\ShopListPurchasesCommand::class, \Blax\Shop\Console\Commands\ShopStatsCommand::class, + \Blax\Shop\Console\Commands\ShopStocksCommand::class, + \Blax\Shop\Console\Commands\ShopAvailabilityCommand::class, + \Blax\Shop\Console\Commands\ShopStocksClaimsCommand::class, \Blax\Shop\Console\Commands\ShopAddExampleProducts::class, \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class, ]); diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index d854e25..43b7d5a 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -6,6 +6,12 @@ namespace Blax\Shop\Traits; use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockType; +use Blax\Shop\Events\StockBecameLow; +use Blax\Shop\Events\StockClaimed; +use Blax\Shop\Events\StockDecreased; +use Blax\Shop\Events\StockDepleted; +use Blax\Shop\Events\StockIncreased; +use Blax\Shop\Events\StockReplenished; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Models\ProductStock; use Carbon\Carbon; @@ -159,7 +165,7 @@ trait HasStocks return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); } - $this->stocks()->create([ + $entry = $this->stocks()->create([ 'quantity' => -$quantity, 'type' => StockType::DECREASE, 'status' => StockStatus::COMPLETED, @@ -171,6 +177,10 @@ trait HasStocks $this->save(); + $availableAfter = $this->getAvailableStock(); + event(new StockDecreased($this, $entry, $availableAfter)); + $this->dispatchStockTransitions($available, $availableAfter); + return true; } @@ -189,7 +199,9 @@ trait HasStocks return false; } - $this->stocks()->create([ + $availableBefore = $this->getAvailableStock(); + + $entry = $this->stocks()->create([ 'quantity' => $quantity, 'type' => StockType::INCREASE, 'status' => StockStatus::COMPLETED, @@ -201,9 +213,43 @@ trait HasStocks $this->save(); + $availableAfter = $this->getAvailableStock(); + event(new StockIncreased($this, $entry, $availableAfter)); + $this->dispatchStockTransitions($availableBefore, $availableAfter); + return true; } + /** + * Compare pre/post available counts and dispatch the boundary-crossing + * stock events (depleted, replenished, became-low, fully-available). + * Called from increase/decrease/claim paths to give listeners a single + * place to react to inventory thresholds without re-querying. + */ + protected function dispatchStockTransitions(int $before, int $after): void + { + if ($before > 0 && $after === 0) { + event(new StockDepleted($this)); + } elseif ($before === 0 && $after > 0) { + event(new StockReplenished($this, $after)); + } + + $threshold = (int) ($this->low_stock_threshold ?? 0); + if ($threshold > 0 && $before > $threshold && $after <= $threshold && $after > 0) { + event(new StockBecameLow($this, $after, $threshold)); + } + + // StockFullyAvailable is intentionally NOT auto-dispatched here: + // getMaxStocksAttribute() sums every INCREASE/RETURN entry over time, + // so it grows whenever new stock arrives or claims release — meaning + // `available === max` collapses to "did we just add inventory?" and + // overlaps with StockIncreased. Hosts that need a domain-meaningful + // "back at full capacity" signal should dispatch the event themselves + // against whatever ceiling they consider canonical (e.g. "physical + // copies on hand" for a library, "max concurrent bookings" for a + // venue). + } + /** * Adjust stock with custom type and status * @@ -317,7 +363,9 @@ trait HasStocks $stockModel = config('shop.models.product_stock', ProductStock::class); - return $stockModel::claim( + $availableBefore = $this->getAvailableStock(); + + $claim = $stockModel::claim( $this, $quantity, $reference, @@ -325,6 +373,13 @@ trait HasStocks $until, $note ); + + if ($claim) { + event(new StockClaimed($this, $claim)); + $this->dispatchStockTransitions($availableBefore, $this->getAvailableStock()); + } + + return $claim; } /** diff --git a/tests/Feature/CommandAvailabilityTest.php b/tests/Feature/CommandAvailabilityTest.php new file mode 100644 index 0000000..ed64d95 --- /dev/null +++ b/tests/Feature/CommandAvailabilityTest.php @@ -0,0 +1,220 @@ + 'Field Notebook', + 'sku' => 'NB-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + $product->increaseStock(3); + + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'NB-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Field Notebook', $output); + $this->assertStringContainsString('NB-1', $output); + $this->assertStringContainsString('May 2026', $output); + $this->assertStringContainsString('Full availability', $output); + $this->assertStringContainsString('MAX AVAILABLE', $output); + $this->assertStringContainsString('DAYS TRACKED', $output); + $this->assertStringContainsString('LOW STOCK DAYS', $output); + $this->assertStringContainsString('[14]', $output, 'today should be bracketed'); + } + + #[Test] + public function it_surfaces_an_infinity_marker_when_stock_is_unmanaged(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + Product::create([ + 'name' => 'Open Source Manual', + 'sku' => 'OSM-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => false, + 'is_visible' => true, + ]); + + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'OSM-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Open Source Manual', $output); + $this->assertStringContainsString('∞', $output); + } + + #[Test] + public function it_marks_out_of_stock_days_as_no_stock_in_the_stats(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + Product::create([ + 'name' => 'Sold Out Title', + 'sku' => 'SOLD-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'SOLD-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('LOW STOCK DAYS', $output); + // A 0-stock product across the whole month means every tracked day is a low-stock day + $this->assertMatchesRegularExpression('/\b35\b/', $output, 'expected 35 low-stock days across the 5-week May grid'); + } + + #[Test] + public function it_fails_gracefully_for_an_unknown_product(): void + { + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'does-not-exist']); + $output = Artisan::output(); + + $this->assertSame(1, $exit); + $this->assertStringContainsString("No product matched 'does-not-exist'.", $output); + } + + #[Test] + public function it_resolves_a_product_by_partial_name(): void + { + Product::create([ + 'name' => 'Hyperion', + 'sku' => '9780553283686', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ])->increaseStock(2); + + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'Hyper']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Hyperion', $output); + } + + #[Test] + public function it_renders_min_max_format_even_when_values_match(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + Product::create([ + 'name' => 'Stable Book', + 'sku' => 'STBL-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ])->increaseStock(3); + + $exit = Artisan::call(ShopAvailabilityCommand::class, ['product' => 'STBL-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('3-3', $output, 'min-max format should be shown even when equal'); + } + + #[Test] + public function it_renders_a_day_detail_with_the_event_timeline(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + $product = Product::create([ + 'name' => 'Detail Book', + 'sku' => 'DT-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + $product->increaseStock(1); + + $exit = Artisan::call(ShopAvailabilityCommand::class, [ + 'product' => 'DT-1', + '--day' => '2026-05-14', + ]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Detail Book', $output); + $this->assertStringContainsString('Thursday, May 14, 2026', $output); + $this->assertStringContainsString('Stock changes throughout the day', $output); + $this->assertStringContainsString('00:00', $output); + $this->assertStringContainsString('1 unit', $output); + $this->assertStringContainsString('MIN STOCK', $output); + $this->assertStringContainsString('MAX STOCK', $output); + } + + #[Test] + public function day_detail_announces_unlimited_when_stock_management_is_off(): void + { + Product::create([ + 'name' => 'Open Source Manual', + 'sku' => 'OSM-2', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => false, + 'is_visible' => true, + ]); + + $exit = Artisan::call(ShopAvailabilityCommand::class, [ + 'product' => 'OSM-2', + '--day' => '2026-05-14', + ]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Unlimited availability all day', $output); + } + + #[Test] + public function it_honours_the_from_and_to_options(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + Product::create([ + 'name' => 'Forever Book', + 'sku' => 'FB-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ])->increaseStock(1); + + $exit = Artisan::call(ShopAvailabilityCommand::class, [ + 'product' => 'FB-1', + '--from' => '2026-07-01', + '--to' => '2026-07-31', + ]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('July 2026', $output); + } +} diff --git a/tests/Feature/CommandListTest.php b/tests/Feature/CommandListTest.php new file mode 100644 index 0000000..324674a --- /dev/null +++ b/tests/Feature/CommandListTest.php @@ -0,0 +1,86 @@ + 'P1', 'sku' => 'P1', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + Product::create(['name' => 'P2', 'sku' => 'P2', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + ProductCategory::create(['name' => 'Fiction', 'slug' => 'fiction']); + + $exit = Artisan::call(ShopListCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Shop listings', $output); + $this->assertStringContainsString('shop:list:products', $output); + $this->assertStringContainsString('shop:list:purchases', $output); + $this->assertStringContainsString('shop:list:categories', $output); + $this->assertStringContainsString('shop:list:orders', $output); + $this->assertStringContainsString('shop:list:carts', $output); + // Counts: 2 products, 1 category + $this->assertMatchesRegularExpression('/shop:list:products\s+\|\s+2 entries/', $output); + $this->assertMatchesRegularExpression('/shop:list:categories\s+\|\s+1 entries/', $output); + } + + #[Test] + public function shop_list_categories_renders_the_catalogue_with_total_count(): void + { + ProductCategory::create(['name' => 'Fiction', 'slug' => 'fiction']); + ProductCategory::create(['name' => 'Non-fiction', 'slug' => 'non-fiction']); + + $exit = Artisan::call(ShopListCategoriesCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Fiction', $output); + $this->assertStringContainsString('Non-fiction', $output); + $this->assertStringContainsString('Total categories: 2', $output); + } + + #[Test] + public function shop_list_orders_handles_empty_state(): void + { + $exit = Artisan::call(ShopListOrdersCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No orders found.', $output); + } + + #[Test] + public function shop_list_carts_filters_to_guest_carts_with_the_flag(): void + { + Cart::create([ + 'customer_type' => 'App\\Models\\User', + 'customer_id' => 'user-1', + ]); + Cart::create(['session_id' => 'guest-sess-1']); + + $exit = Artisan::call(ShopListCartsCommand::class, ['--guest' => true]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('Showing 1 cart', $output); + } +} diff --git a/tests/Feature/CommandStocksTest.php b/tests/Feature/CommandStocksTest.php new file mode 100644 index 0000000..97e2731 --- /dev/null +++ b/tests/Feature/CommandStocksTest.php @@ -0,0 +1,170 @@ + 'Alpha', 'sku' => 'A', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => true, 'is_visible' => true]); + $a->increaseStock(5); + + $b = Product::create(['name' => 'Bravo', 'sku' => 'B', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + + $exit = Artisan::call(ShopStocksCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Alpha', $output); + $this->assertStringContainsString('Bravo', $output); + $this->assertStringContainsString('Assigned', $output); + $this->assertStringContainsString('Available', $output); + $this->assertStringContainsString('Claimed', $output); + $this->assertStringContainsString('∞', $output, 'manage_stock=false products report as ∞'); + } + + #[Test] + public function shop_stocks_with_arg_renders_a_detail_view_with_totals_and_ledger(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + $product = Product::create([ + 'name' => 'Hyperion', + 'sku' => 'HYP-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + $product->increaseStock(5); + $product->decreaseStock(2); + + $exit = Artisan::call(ShopStocksCommand::class, ['product' => 'HYP-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Hyperion', $output); + $this->assertStringContainsString('ASSIGNED', $output); + $this->assertStringContainsString('USED', $output); + $this->assertStringContainsString('AVAILABLE', $output); + $this->assertStringContainsString('Recent stock ledger', $output); + $this->assertStringContainsString('increase', $output); + $this->assertStringContainsString('decrease', $output); + } + + #[Test] + public function shop_stocks_detail_announces_unlimited_when_stock_management_is_off(): void + { + Product::create([ + 'name' => 'Open Manual', + 'sku' => 'OM-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => false, + 'is_visible' => true, + ]); + + $exit = Artisan::call(ShopStocksCommand::class, ['product' => 'OM-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Stock management is OFF', $output); + } + + #[Test] + public function shop_stocks_fails_gracefully_for_unknown_product(): void + { + $exit = Artisan::call(ShopStocksCommand::class, ['product' => 'does-not-exist']); + $output = Artisan::output(); + + $this->assertSame(1, $exit); + $this->assertStringContainsString("No product matched 'does-not-exist'.", $output); + } + + #[Test] + public function shop_stocks_claims_lists_active_and_upcoming_claims(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + $product = Product::create([ + 'name' => 'Solitaire', + 'sku' => 'SOL-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + $product->increaseStock(2); + $product->claimStock(1, null, Carbon::parse('2026-05-14 08:00:00'), Carbon::parse('2026-05-14 18:00:00'), 'active claim'); + $product->claimStock(1, null, Carbon::parse('2026-05-20 09:00:00'), Carbon::parse('2026-05-21 09:00:00'), 'future claim'); + + $exit = Artisan::call(ShopStocksClaimsCommand::class, ['product' => 'SOL-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Solitaire', $output); + $this->assertStringContainsString('Claim From', $output); + $this->assertStringContainsString('active', $output); + $this->assertStringContainsString('upcoming', $output); + $this->assertStringContainsString('active claim', $output); + $this->assertStringContainsString('future claim', $output); + } + + #[Test] + public function shop_stocks_claims_with_active_filter_hides_upcoming_claims(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00')); + + $product = Product::create([ + 'name' => 'Solitaire', + 'sku' => 'SOL-2', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ]); + $product->increaseStock(2); + $product->claimStock(1, null, Carbon::parse('2026-05-14 08:00:00'), Carbon::parse('2026-05-14 18:00:00'), 'active claim'); + $product->claimStock(1, null, Carbon::parse('2026-05-20 09:00:00'), Carbon::parse('2026-05-21 09:00:00'), 'future claim'); + + $exit = Artisan::call(ShopStocksClaimsCommand::class, ['product' => 'SOL-2', '--active' => true]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('active claim', $output); + $this->assertStringNotContainsString('future claim', $output); + } + + #[Test] + public function shop_stocks_claims_reports_empty_when_none_pending(): void + { + Product::create([ + 'name' => 'Quiet Book', + 'sku' => 'Q-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ])->increaseStock(3); + + $exit = Artisan::call(ShopStocksClaimsCommand::class, ['product' => 'Q-1']); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('no pending claims found', $output); + } +} diff --git a/tests/Feature/EventsWiredUpTest.php b/tests/Feature/EventsWiredUpTest.php new file mode 100644 index 0000000..e84ec71 --- /dev/null +++ b/tests/Feature/EventsWiredUpTest.php @@ -0,0 +1,271 @@ + 'Test Product', + 'sku' => 'EV-'.uniqid(), + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'is_visible' => true, + ], $overrides)); + } + + // ─── Stock-level transitions ────────────────────────────────────────── + + #[Test] + public function increase_stock_dispatches_stock_increased(): void + { + Event::fake([StockIncreased::class]); + + $product = $this->newProduct(); + $product->increaseStock(3); + + Event::assertDispatched(StockIncreased::class, fn (StockIncreased $e) => + $e->product->is($product) && $e->availableAfter === 3 + ); + } + + #[Test] + public function decrease_stock_dispatches_stock_decreased(): void + { + $product = $this->newProduct(); + $product->increaseStock(3); + + Event::fake([StockDecreased::class]); + + $product->decreaseStock(2); + + Event::assertDispatched(StockDecreased::class, fn (StockDecreased $e) => + $e->product->is($product) && $e->availableAfter === 1 + ); + } + + #[Test] + public function depleting_the_last_unit_dispatches_stock_depleted(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + Event::fake([StockDepleted::class]); + + $product->decreaseStock(1); + + Event::assertDispatched(StockDepleted::class, fn (StockDepleted $e) => $e->product->is($product)); + } + + #[Test] + public function restocking_a_depleted_product_dispatches_stock_replenished(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + $product->decreaseStock(1); + + Event::fake([StockReplenished::class]); + + $product->increaseStock(2); + + Event::assertDispatched(StockReplenished::class, fn (StockReplenished $e) => + $e->product->is($product) && $e->availableAfter === 2 + ); + } + + #[Test] + public function crossing_below_the_low_stock_threshold_dispatches_stock_became_low(): void + { + $product = $this->newProduct(['low_stock_threshold' => 2]); + $product->increaseStock(5); + + Event::fake([StockBecameLow::class]); + + // 5 → 4 stays above threshold (2); 4 → 2 crosses it. + $product->decreaseStock(3); + + Event::assertDispatched(StockBecameLow::class, fn (StockBecameLow $e) => + $e->product->is($product) && $e->availableAfter === 2 && $e->threshold === 2 + ); + } + + #[Test] + public function low_stock_threshold_does_not_fire_for_zero_after(): void + { + $product = $this->newProduct(['low_stock_threshold' => 2]); + $product->increaseStock(3); + + Event::fake([StockBecameLow::class, StockDepleted::class]); + + // Going from 3 → 0 should fire Depleted, not BecameLow. + $product->decreaseStock(3); + + Event::assertNotDispatched(StockBecameLow::class); + Event::assertDispatched(StockDepleted::class); + } + + #[Test] + public function claim_stock_dispatches_stock_claimed(): void + { + $product = $this->newProduct(); + $product->increaseStock(2); + + Event::fake([StockClaimed::class]); + + $claim = $product->claimStock(1); + + $this->assertNotNull($claim); + Event::assertDispatched(StockClaimed::class, fn (StockClaimed $e) => + $e->product->is($product) && $e->entry->is($claim) + ); + } + + #[Test] + public function releasing_a_claim_manually_dispatches_stock_released(): void + { + $product = $this->newProduct(); + $product->increaseStock(2); + $claim = $product->claimStock(1); + + Event::fake([StockReleased::class, StockClaimExpired::class]); + + $claim->release(); + + Event::assertDispatched(StockReleased::class); + Event::assertNotDispatched(StockClaimExpired::class); + } + + #[Test] + public function release_expired_dispatches_stock_claim_expired_not_stock_released(): void + { + $product = $this->newProduct(); + $product->increaseStock(2); + + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 10:00:00'), + Carbon::parse('2026-05-14 11:00:00'), + 'short claim' + ); + + Carbon::setTestNow(Carbon::parse('2026-05-14 12:00:00')); // past expiry + + Event::fake([StockReleased::class, StockClaimExpired::class]); + + ProductStock::releaseExpired(); + + Event::assertDispatched(StockClaimExpired::class); + Event::assertNotDispatched(StockReleased::class); + } + + // ─── Model-level events ─────────────────────────────────────────────── + + #[Test] + public function publishing_a_new_product_dispatches_product_published(): void + { + Event::fake([ProductPublished::class]); + + $product = $this->newProduct(); // created already PUBLISHED + + Event::assertDispatched(ProductPublished::class, fn (ProductPublished $e) => $e->product->is($product)); + } + + #[Test] + public function moving_a_product_away_from_published_dispatches_product_unpublished(): void + { + $product = $this->newProduct(); + + Event::fake([ProductUnpublished::class]); + + $product->status = ProductStatus::DRAFT; + $product->save(); + + Event::assertDispatched(ProductUnpublished::class, fn (ProductUnpublished $e) => $e->product->is($product)); + } + + #[Test] + public function deleting_a_product_dispatches_product_deleted(): void + { + $product = $this->newProduct(); + + Event::fake([ProductDeleted::class]); + + $product->delete(); + + Event::assertDispatched(ProductDeleted::class, fn (ProductDeleted $e) => $e->product->is($product)); + } + + #[Test] + public function creating_a_cart_dispatches_cart_created(): void + { + Event::fake([CartCreated::class]); + + $cart = Cart::create(['session_id' => 'sess-evt-1']); + + Event::assertDispatched(CartCreated::class, fn (CartCreated $e) => $e->cart->is($cart)); + } + + #[Test] + public function creating_an_order_dispatches_order_created(): void + { + Event::fake([OrderCreated::class]); + + $order = Order::create(['currency' => 'EUR']); + + Event::assertDispatched(OrderCreated::class, fn (OrderCreated $e) => $e->order->is($order)); + } + + #[Test] + public function creating_a_purchase_dispatches_purchase_created(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + Event::fake([PurchaseCreated::class]); + + $purchase = ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'user-x', + 'purchaser_type' => 'App\\Models\\User', + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + ]); + + Event::assertDispatched(PurchaseCreated::class, fn (PurchaseCreated $e) => $e->purchase->is($purchase)); + } +}