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.
This commit is contained in:
Fabian @ Blax Software 2026-05-17 11:24:43 +02:00
parent 8eb1802ef8
commit f55c7e11df
54 changed files with 2578 additions and 9 deletions

View File

@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\Product;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ShopAvailabilityCommand extends Command
{
protected $signature = 'shop:stocks:availability
{product : Product ID, slug, SKU, or partial name}
{--from= : First day of the calendar (YYYY-MM-DD, defaults to start of current month)}
{--to= : Last day of the calendar (YYYY-MM-DD, defaults to end of the --from month)}
{--day= : Show a detail timeline for a single day instead of the calendar (YYYY-MM-DD)}';
protected $description = 'Render an ASCII availability calendar for a product (pool, loanable, booking, or simple)';
private const CELL_WIDTH = 6;
public function handle(): int
{
$product = $this->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(' <fg=cyan;options=bold>'.$product->name.'</>');
$this->line(' <fg=gray>type:</> '.$type.' <fg=gray>sku:</> '.$sku.' <fg=gray>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(
' <fg=green;options=bold>Available %s</> <fg=yellow>Currently claimed %d</> <fg=blue>Future claims %d</> <fg=magenta>Active & planned %d</>',
$this->infinityOr($available),
$currentClaims,
$futureClaims,
$activeAndPlanned,
));
$this->newLine();
}
private function renderMonthLabel(Carbon $focus): void
{
$this->line(' <options=bold>'.$focus->format('F Y').'</>');
$this->newLine();
}
private function renderLegend(): void
{
$this->line(
' <fg=green>━━━━━</> Full availability '.
'<fg=yellow>━━━━━</> Partial '.
'<fg=red>━━━━━</> No stock'
);
$this->newLine();
}
/**
* @param array<string, array{min: int, max: int}> $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 = "<fg=cyan;options=bold>$numCell</>";
} elseif (! $inMonth) {
$numCell = "<fg=gray>$numCell</>";
}
$dayLine .= $numCell.'│';
$bar = str_repeat('━', $w - 2);
$barCell = ' '."<fg=$color;options=bold>$bar</>".' ';
$barLine .= $barCell.'│';
$stat = $this->infinityOr($cell['min']).'-'.$this->infinityOr($cell['max']);
$statCell = $this->pad($stat, $w);
if (! $inMonth) {
$statCell = "<fg=gray>$statCell</>";
} else {
$statCell = "<fg=$color>$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<string, array{min: int, max: int}>} $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 .= "<fg=$color>".$this->pad($label, $boxWidth).'</>│';
$valueLine .= "<fg=$color;options=bold>".$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(' <fg=cyan;options=bold>'.$day->format('l, F j, Y').'</>');
$this->newLine();
if ($timeline === PHP_INT_MAX) {
$this->line(' <fg=green;options=bold>Unlimited availability all day.</> <fg=gray>(manage_stock = false)</>');
$this->newLine();
return self::SUCCESS;
}
// $timeline is array<string HH:MM, int available>
$this->line(' <fg=gray>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 = "<fg=$color;options=bold>".$this->pad($availText, $availWidth).'</>';
$noteCell = "<fg=$color>".$this->pad($note, $noteWidth).'</>';
$this->line(' │'.$timeCell.'│'.$availCell.'│'.$noteCell.'│');
}
$this->line(' '.$bot);
$this->newLine();
$values = array_values($timeline);
$this->line(sprintf(
' <fg=cyan>MIN STOCK</> %d <fg=cyan>MAX STOCK</> %d <fg=gray>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);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCartsCommand extends Command
{
protected $signature = 'shop:list:carts
{--guest : Only show guest (session-based) carts}
{--with-items : Include item counts}
{--limit=50 : Maximum number of carts to display}';
protected $description = 'List shopping carts (active or guest), optionally with item counts';
public function handle(): int
{
$model = config('shop.models.cart');
$query = $model::query()->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)
: '<guest>';
$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;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCategoriesCommand extends Command
{
protected $signature = 'shop:list:categories
{--with-products : Include the count of products in each category}';
protected $description = 'List all product categories';
public function handle(): int
{
$model = config('shop.models.product_category');
$query = $model::query()->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;
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCommand extends Command
{
protected $signature = 'shop:list';
protected $description = 'Show every listable resource in the shop along with its total count';
/**
* Keyed by the subcommand suffix; value is the config key under `shop.models`
* whose bound model is counted. Adding a new shop:list:<thing> 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(' <fg=cyan;options=bold>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(' <fg=gray>Run any of the above to see the full table. Most accept filter options — `<command> --help` shows what.</>');
$this->newLine();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListOrdersCommand extends Command
{
protected $signature = 'shop:list:orders
{--user= : Filter by user ID}
{--status= : Filter by order status}
{--limit=50 : Maximum number of orders to display}';
protected $description = 'List orders, optionally filtered by user or status';
public function handle(): int
{
$model = config('shop.models.order');
$query = $model::query()->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;
}
}

View File

@ -8,7 +8,7 @@ use Illuminate\Console\Command;
class ShopListProductsCommand extends Command class ShopListProductsCommand extends Command
{ {
protected $signature = 'shop:list-products protected $signature = 'shop:list:products
{--with-actions : Include action counts} {--with-actions : Include action counts}
{--with-purchases : Include purchase counts} {--with-purchases : Include purchase counts}
{--enabled : Only show enabled products} {--enabled : Only show enabled products}

View File

@ -8,7 +8,7 @@ use Illuminate\Console\Command;
class ShopListPurchasesCommand extends Command class ShopListPurchasesCommand extends Command
{ {
protected $signature = 'shop:list-purchases protected $signature = 'shop:list:purchases
{product? : Product ID to filter by} {product? : Product ID to filter by}
{--user= : Filter by user ID} {--user= : Filter by user ID}
{--status= : Filter by status} {--status= : Filter by status}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ShopStocksClaimsCommand extends Command
{
protected $signature = 'shop:stocks:claims
{product? : Limit to one product (ID, slug, SKU, or partial name). Omit to list claims across the catalogue.}
{--active : Only show claims that are active right now}
{--limit=50 : Maximum number of claims to display}';
protected $description = 'List pending stock claims (active or upcoming reservations) — useful for "why is this not available?" investigations';
public function handle(): int
{
$limit = max(1, (int) $this->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(' <fg=cyan;options=bold>'.$product->name.'</> <fg=gray>('.($product->sku ?: $product->id).')</>');
} else {
$this->newLine();
$this->line(' <fg=cyan;options=bold>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(' <fg=gray>Filter: currently active only</>');
}
$claims = $query
->orderBy('claimed_from')
->orderBy('expires_at')
->limit($limit)
->get();
$this->newLine();
if ($claims->isEmpty()) {
$this->line(' <fg=gray>(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(' <fg=gray>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;
}
}

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Illuminate\Console\Command;
class ShopStocksCommand extends Command
{
protected $signature = 'shop:stocks
{product? : Product ID, slug, SKU, or partial name. Omit to see a stock overview across all products.}
{--limit=20 : Maximum number of ledger entries to show in detail view}';
protected $description = 'Show stock totals and the recent ledger for a product, or an overview across all products';
public function handle(): int
{
$identifier = $this->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(' <fg=gray>Total products: '.$products->count().' '.
'Run <fg=cyan>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(' <fg=cyan;options=bold>'.$product->name.'</>');
$this->line(' <fg=gray>type:</> '.$type.' <fg=gray>sku:</> '.$sku.' <fg=gray>id:</> '.$product->id);
$this->newLine();
if (! $product->manage_stock) {
$this->line(' <fg=green;options=bold>Stock management is OFF.</> <fg=gray>(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(' <fg=gray>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(' <fg=gray>(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<array{0: string, 1: int, 2: string}> $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 .= "<fg=$color>".$this->pad($label, $boxWidth).'</>│';
$valueLine .= "<fg=$color;options=bold>".$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;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a booking is cancelled before it would have started
* shopper cancellation, no-show policy, payment failure, etc. The package
* does not auto-release stock claims on cancellation; that's a listener's
* job (often paired with {@see StockReleased}).
*/
class BookingCancelled
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $booking) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Booking-flavoured counterpart to {@see LoanCreated} / {@see PurchaseCreated}.
* Hosts that build on BOOKING-typed products dispatch this when a
* reservation is confirmed (after the booking calendar has been claimed
* and the price is locked in).
*/
class BookingConfirmed
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $booking) {}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched by the cart-cleanup sweeper (see
* {@see \Blax\Shop\Console\Commands\ShopCleanupCartsCommand}) when a cart
* has been inactive past the abandon threshold but is not yet hard-expired.
* Listeners typically use this to send recovery emails or release temporary
* stock claims attached to the cart.
*/
class CartAbandoned
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched at the moment a cart becomes an order usually from the
* checkout flow after the order row is persisted and the cart is marked
* converted. Use to trigger receipt emails, fulfilment workflows, analytics
* conversion pings, etc.
*/
class CartConverted
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public Order $order,
) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fires when a new cart row is persisted. Dispatched automatically by
* {@see Cart}'s `$dispatchesEvents` map. Listeners commonly use this for
* analytics ("session X started a cart") or to attach a default currency
* inferred from the request.
*/
class CartCreated
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a cart hits its `expires_at` (or the cleanup sweeper
* decides to retire it). The cart is about to be deleted; listeners can
* snapshot useful analytics before it goes away.
*/
class CartExpired
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a {@see CartItem} row is created (via the cart service
* or directly). Carries both the cart and the new item so listeners can
* recompute totals, surface "added to cart" toasts, or claim stock.
*/
class CartItemAdded
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a cart item is removed (either by the shopper or via the
* cart service when a product becomes unavailable). The model carried here
* is the already-deleted instance listeners can read its attributes but
* not save back.
*/
class CartItemRemoved
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a cart item's quantity, dates, or pricing fields change.
* Distinct from {@see CartItemAdded} so listeners can treat "added once"
* and "quantity ticked up" as separate signals (e.g. for funnel metrics).
*/
class CartItemUpdated
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is cancelled by the shopper, by support, or
* by a payment-failure path that decides the order can't proceed. Stock
* claims tied to the order should be released by listeners (the package
* does not do this automatically).
*/
class OrderCancelled
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically when an {@see Order} row is created (via the
* model's `$dispatchesEvents` map). Distinct from {@see CartConverted},
* which also carries the originating cart listen to whichever signal
* matches your domain language.
*/
class OrderCreated
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is marked fulfilled (shipped, delivered, picked
* up, or otherwise handed off the package is fulfilment-channel
* agnostic). Hosts that distinguish "shipped" from "delivered" can listen
* here for the final hand-off and define their own intermediate events.
*/
class OrderFulfilled
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

21
src/Events/OrderPaid.php Normal file
View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order transitions to a paid state (in-app payment
* captured, Stripe webhook reconciled, or manual mark-as-paid action).
* Use this to fire off fulfilment workflows or payout reconciliation.
*/
class OrderPaid
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is refunded partially or fully. $amount
* carries the refunded amount (in the order's currency) so listeners can
* keep running totals without re-querying refund history.
*/
class OrderRefunded
{
use Dispatchable, SerializesModels;
public function __construct(
public Order $order,
public float $amount,
public bool $partial,
) {}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} when a row is deleted (or
* soft-deleted, if the host enables it). Useful for search-index cleanup,
* Stripe archival, or cache invalidation.
*/
class ProductDeleted
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a product's status transitions to PUBLISHED (or it is created
* directly in the published state). Listeners typically push to the public
* sales surface, kick off launch notifications, or warm caches.
*/
class ProductPublished
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a product moves away from PUBLISHED (to DRAFT, ARCHIVED,
* etc.). Listeners commonly use this to retract the product from sales
* surfaces or freeze ongoing operations referencing it.
*/
class ProductUnpublished
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Generic counterpart to {@see LoanCreated} / {@see BookingConfirmed}
* fires for any newly created ProductPurchase row regardless of domain
* shape. Listen here when you don't care which kind of purchase it is.
*
* Hosts can rely on the model's $dispatchesEvents map, or call
* `event(new PurchaseCreated($purchase))` directly when assembling rows
* outside the normal save() path.
*/
class PurchaseCreated
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $purchase) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a single purchase (line-item, loan, or booking) is
* refunded separate from {@see OrderRefunded}, which represents the
* order-level event. Both can fire from the same operator action; listen
* to whichever level matches your reporting needs.
*/
class PurchaseRefunded
{
use Dispatchable, SerializesModels;
public function __construct(
public ProductPurchase $purchase,
public float $amount,
) {}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched once when available stock crosses below the product's
* `low_stock_threshold` (and was above it immediately before). Fires from
* stock-change paths in {@see \Blax\Shop\Traits\HasStocks}; the post-change
* available count plus the threshold are carried in the payload so a
* listener can build the alert message without re-querying.
*/
class StockBecameLow
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
public int $threshold,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from the {@see \Blax\Shop\Console\Commands\ReleaseExpiredStocks}
* sweeper when a pending claim's `expires_at` has passed and the package
* automatically returns its quantity to available stock. Pair with
* {@see StockReleased} if a listener needs to handle either path uniformly.
*/
class StockClaimExpired
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::claimStock()} after a
* reservation (PENDING/CLAIMED row) is created. The associated $reference
* (cart, booking, anything polymorphic) is reachable via the $entry's
* `reference_type` / `reference_id` columns.
*/
class StockClaimed
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::decreaseStock()} after
* a negative stock entry is written. Use this to track depletion sources,
* trigger reorder workflows, or fan out availability-change notifications.
*/
class StockDecreased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
public int $availableAfter,
) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched the moment available stock drops to zero (and was positive
* immediately before). The product can no longer be sold/loaned/booked at
* its current quantity until restocked. Listeners typically hide the
* product from sales surfaces or send "now sold out" notifications.
*/
class StockDepleted
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Host-dispatched event marking "stock is back at full capacity" useful
* for inventory health dashboards or "no copies checked out" signals.
*
* Not fired automatically by the package: the canonical notion of "max"
* varies by domain (library physical copies, venue capacity, shelf SKU
* count) and the package's stock ledger is grow-only, so any auto-rule
* would overlap with {@see StockIncreased}. Hosts dispatch this themselves
* when their domain-specific ceiling is met.
*/
class StockFullyAvailable
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::increaseStock()} after
* a positive stock entry is written. Listeners commonly use this to log
* inventory deliveries, push to an external WMS, or recompute aggregates.
*/
class StockIncreased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
public int $availableAfter,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a previously-pending claim is explicitly released back to
* the available pool (typically via {@see ProductStock::release()} or a
* cart-abandonment path). Distinct from {@see StockClaimExpired}, which
* fires from the scheduled sweeper.
*/
class StockReleased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a product transitions from "out of stock" back to having
* at least one available unit. Counterpart to {@see StockDepleted}. Useful
* for waitlist fan-outs ("the book you wanted is back") or reactivating the
* product on sales surfaces.
*/
class StockReplenished
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
) {}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired on Stripe payment failure (card declined, authentication failed,
* provider error). Listeners typically retry, notify the shopper, or roll
* back any optimistic state the order had assumed.
*/
class StripePaymentFailed
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public array $payload,
public ?string $reason = null,
) {}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when Stripe confirms a successful charge both the matched Order
* (if the package can resolve it from the payment_intent metadata) and the
* raw Stripe payload are carried so listeners can act on either layer.
*
* If the Order resolution failed (orphan payment), $order is null.
*/
class StripePaymentSucceeded
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public array $payload,
) {}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a ProductPrice is pushed to Stripe and the resulting
* Stripe price ID is persisted. Distinct from {@see StripeProductSynced}
* so listeners can react specifically when pricing changes propagate.
*/
class StripePriceSynced
{
use Dispatchable, SerializesModels;
public function __construct(
public ProductPrice $price,
public string $stripePriceId,
) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a Product is pushed to Stripe and the resulting
* stripe_product_id has been persisted on the model. Useful for confirming
* the round-trip succeeded or for downstream replication to other catalog
* systems that derive from Stripe IDs.
*/
class StripeProductSynced
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public string $stripeProductId,
) {}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when Stripe reports a refund has been processed separate
* from {@see OrderRefunded} (which is the package's domain event) so
* listeners can distinguish refund decisions made internally from refunds
* confirmed by the gateway.
*/
class StripeRefundProcessed
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public float $amount,
public array $payload,
) {}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Catch-all event fired for every incoming Stripe webhook the package
* processes. Listen here when you need a single hook for audit/logging or
* to route to custom handlers based on `$type`. The more specific events
* ({@see StripePaymentSucceeded}, {@see StripePaymentFailed}, etc.) carry
* the same payload but only fire for their respective Stripe types.
*
* `$payload` is the decoded JSON body as Stripe sent it; do not mutate it.
*/
class StripeWebhookReceived
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public string $type,
public array $payload,
) {}
}

View File

@ -63,6 +63,10 @@ class Cart extends Model
'is_ready_to_checkout', 'is_ready_to_checkout',
]; ];
protected $dispatchesEvents = [
'created' => \Blax\Shop\Events\CartCreated::class,
];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
parent::__construct($attributes); parent::__construct($attributes);

View File

@ -88,6 +88,10 @@ class Order extends Model
'is_fully_paid', 'is_fully_paid',
]; ];
protected $dispatchesEvents = [
'created' => \Blax\Shop\Events\OrderCreated::class,
];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
parent::__construct($attributes); parent::__construct($attributes);

View File

@ -7,6 +7,9 @@ namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Workkit\Traits\HasMetaTranslation;
use Blax\Shop\Events\ProductCreated; 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\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;
@ -138,6 +141,7 @@ class Product extends Model implements Purchasable, Cartable
protected $dispatchesEvents = [ protected $dispatchesEvents = [
'created' => ProductCreated::class, 'created' => ProductCreated::class,
'updated' => ProductUpdated::class, 'updated' => ProductUpdated::class,
'deleted' => ProductDeleted::class,
]; ];
protected $hidden = [ protected $hidden = [
@ -189,6 +193,30 @@ class Product extends Model implements Purchasable, Cartable
if (config('shop.cache.enabled')) { if (config('shop.cache.enabled')) {
Cache::forget(config('shop.cache.prefix') . 'product:' . $model->id); 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) { static::deleted(function ($model) {

View File

@ -84,6 +84,10 @@ class ProductPurchase extends Model
'meta' => 'object', 'meta' => 'object',
]; ];
protected $dispatchesEvents = [
'created' => \Blax\Shop\Events\PurchaseCreated::class,
];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
parent::__construct($attributes); parent::__construct($attributes);

View File

@ -213,13 +213,13 @@ class ProductStock extends Model
* *
* @return bool True if released successfully, false if not pending * @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) { if ($this->status !== StockStatus::PENDING) {
return false; return false;
} }
return DB::transaction(function () { return DB::transaction(function () use ($expired) {
// Mark claim as completed (released) // Mark claim as completed (released)
$this->status = StockStatus::COMPLETED; $this->status = StockStatus::COMPLETED;
$this->save(); $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 creates a RETURN entry to offset the DECREASE that was created when claiming
$this->product->increaseStock($this->quantity, StockType::RETURN); $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; return true;
}); });
} }
@ -302,7 +308,7 @@ class ProductStock extends Model
$count = 0; $count = 0;
foreach ($expired as $stock) { foreach ($expired as $stock) {
if ($stock->release()) { if ($stock->release(expired: true)) {
$count++; $count++;
} }
} }

View File

@ -47,11 +47,18 @@ class ShopServiceProvider extends ServiceProvider
ShopReinstallCommand::class, ShopReinstallCommand::class,
ShopCleanupCartsCommand::class, ShopCleanupCartsCommand::class,
\Blax\Shop\Console\Commands\ReleaseExpiredStocks::class, \Blax\Shop\Console\Commands\ReleaseExpiredStocks::class,
\Blax\Shop\Console\Commands\ShopListCommand::class,
\Blax\Shop\Console\Commands\ShopListProductsCommand::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\ShopToggleActionCommand::class,
\Blax\Shop\Console\Commands\ShopTestActionCommand::class, \Blax\Shop\Console\Commands\ShopTestActionCommand::class,
\Blax\Shop\Console\Commands\ShopListPurchasesCommand::class,
\Blax\Shop\Console\Commands\ShopStatsCommand::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\ShopAddExampleProducts::class,
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class, \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
]); ]);

View File

@ -6,6 +6,12 @@ namespace Blax\Shop\Traits;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; 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\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\ProductStock; use Blax\Shop\Models\ProductStock;
use Carbon\Carbon; use Carbon\Carbon;
@ -159,7 +165,7 @@ trait HasStocks
return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}");
} }
$this->stocks()->create([ $entry = $this->stocks()->create([
'quantity' => -$quantity, 'quantity' => -$quantity,
'type' => StockType::DECREASE, 'type' => StockType::DECREASE,
'status' => StockStatus::COMPLETED, 'status' => StockStatus::COMPLETED,
@ -171,6 +177,10 @@ trait HasStocks
$this->save(); $this->save();
$availableAfter = $this->getAvailableStock();
event(new StockDecreased($this, $entry, $availableAfter));
$this->dispatchStockTransitions($available, $availableAfter);
return true; return true;
} }
@ -189,7 +199,9 @@ trait HasStocks
return false; return false;
} }
$this->stocks()->create([ $availableBefore = $this->getAvailableStock();
$entry = $this->stocks()->create([
'quantity' => $quantity, 'quantity' => $quantity,
'type' => StockType::INCREASE, 'type' => StockType::INCREASE,
'status' => StockStatus::COMPLETED, 'status' => StockStatus::COMPLETED,
@ -201,9 +213,43 @@ trait HasStocks
$this->save(); $this->save();
$availableAfter = $this->getAvailableStock();
event(new StockIncreased($this, $entry, $availableAfter));
$this->dispatchStockTransitions($availableBefore, $availableAfter);
return true; 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 * Adjust stock with custom type and status
* *
@ -317,7 +363,9 @@ trait HasStocks
$stockModel = config('shop.models.product_stock', ProductStock::class); $stockModel = config('shop.models.product_stock', ProductStock::class);
return $stockModel::claim( $availableBefore = $this->getAvailableStock();
$claim = $stockModel::claim(
$this, $this,
$quantity, $quantity,
$reference, $reference,
@ -325,6 +373,13 @@ trait HasStocks
$until, $until,
$note $note
); );
if ($claim) {
event(new StockClaimed($this, $claim));
$this->dispatchStockTransitions($availableBefore, $this->getAvailableStock());
}
return $claim;
} }
/** /**

View File

@ -0,0 +1,220 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopAvailabilityCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
class CommandAvailabilityTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_renders_a_calendar_for_a_simple_product_with_stock(): void
{
Carbon::setTestNow(Carbon::parse('2026-05-14 09:00:00'));
$product = Product::create([
'name' => '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);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopListCartsCommand;
use Blax\Shop\Console\Commands\ShopListCategoriesCommand;
use Blax\Shop\Console\Commands\ShopListCommand;
use Blax\Shop\Console\Commands\ShopListOrdersCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
class CommandListTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function shop_list_dispatcher_shows_every_listable_with_a_total_count(): void
{
Product::create(['name' => '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('<guest>', $output);
$this->assertStringContainsString('Showing 1 cart', $output);
}
}

View File

@ -0,0 +1,170 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopStocksClaimsCommand;
use Blax\Shop\Console\Commands\ShopStocksCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
class CommandStocksTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function shop_stocks_without_arg_lists_every_product_with_stock_columns(): void
{
$a = Product::create(['name' => '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);
}
}

View File

@ -0,0 +1,271 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Events\CartCreated;
use Blax\Shop\Events\OrderCreated;
use Blax\Shop\Events\ProductDeleted;
use Blax\Shop\Events\ProductPublished;
use Blax\Shop\Events\ProductUnpublished;
use Blax\Shop\Events\PurchaseCreated;
use Blax\Shop\Events\StockBecameLow;
use Blax\Shop\Events\StockClaimed;
use Blax\Shop\Events\StockClaimExpired;
use Blax\Shop\Events\StockDecreased;
use Blax\Shop\Events\StockDepleted;
use Blax\Shop\Events\StockIncreased;
use Blax\Shop\Events\StockReleased;
use Blax\Shop\Events\StockReplenished;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
class EventsWiredUpTest extends TestCase
{
use RefreshDatabase;
private function newProduct(array $overrides = []): Product
{
return Product::create(array_merge([
'name' => '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));
}
}