feat(purchase): implement InsufficientStockException for handling stock errors in checkout process

This commit is contained in:
2026-08-12 14:45:55 -03:00
parent 8e94cf7856
commit 1b22989252
3 changed files with 98 additions and 18 deletions

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Purchase\Exceptions;
use RuntimeException;
class InsufficientStockException extends RuntimeException
{
/**
* @param array<int, array{
* index: int,
* catalog_item_id: int,
* variant_id: int|null,
* requested_quantity: int,
* available_quantity: int,
* message: string
* }> $unavailableItems
*/
public function __construct(
public readonly array $unavailableItems,
) {
parent::__construct(
collect($unavailableItems)->pluck('message')->unique()->implode(' '),
);
}
/** @return array<string, array<int, string>> */
public function errors(): array
{
return collect($this->unavailableItems)
->mapWithKeys(fn (array $item): array => [
"direct_items.{$item['index']}.cantidad" => [$item['message']],
])
->all();
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
@@ -70,6 +71,7 @@ class StartCheckoutService
$lines = collect(array_values($directItems))
->map(function (array $item, int $index): array {
return [
'index' => $index,
'catalog_item_id' => (int) $item['catalog_item_id'],
'variant_id' => isset($item['variant_id']) ? (int) $item['variant_id'] : null,
'quantity' => (int) $item['cantidad'],
@@ -124,30 +126,32 @@ class StartCheckoutService
);
});
$unavailableItems = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
if ($availableQuantity === null || $availableQuantity >= $line['quantity']) {
return null;
}
return $this->unavailableItem($line, $availableQuantity);
})
->filter()
->values()
->all();
if ($unavailableItems !== []) {
throw new InsufficientStockException($unavailableItems);
}
foreach ($resolvedLines as $line) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
if ($availableQuantity !== null && $availableQuantity < $line['quantity']) {
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
]);
}
try {
$this->inventory->reserve($line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
throw new InsufficientStockException([
$this->unavailableItem($line, $availableQuantity),
]);
}
}
@@ -176,6 +180,33 @@ class StartCheckoutService
return $this->loadPurchase($purchase);
}
/**
* @param array<string, mixed> $line
* @return array{
* index: int,
* catalog_item_id: int,
* variant_id: int|null,
* requested_quantity: int,
* available_quantity: int,
* message: string
* }
*/
private function unavailableItem(array $line, int $availableQuantity): array
{
return [
'index' => $line['index'],
'catalog_item_id' => $line['catalog_item_id'],
'variant_id' => $line['variant_id'],
'requested_quantity' => $line['quantity'],
'available_quantity' => $availableQuantity,
'message' => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
];
}
/** @param array<string, mixed> $purchaseData */
private function startFromCart(
Tenant $tenant,

View File

@@ -2,6 +2,7 @@
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Http\Middleware\EnsureAdminAppTenant;
use App\Http\Middleware\EnsureScannerTenant;
use App\Http\Middleware\EnsureTenantHasMenu;
@@ -74,6 +75,18 @@ return Application::configure(basePath: dirname(__DIR__))
'message' => __('api.errors.forbidden'),
], 403);
});
$exceptions->render(function (InsufficientStockException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'purchase.insufficient_stock',
'message' => $exception->getMessage(),
'errors' => $exception->errors(),
'unavailable_items' => $exception->unavailableItems,
], 422);
});
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;