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:
parent
8eb1802ef8
commit
f55c7e11df
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue