feat(catalog): expose availability policies

This commit is contained in:
2026-08-24 14:32:55 -03:00
parent ceb1d1f1af
commit 2fd6851b40
6 changed files with 108 additions and 63 deletions

View File

@@ -38,11 +38,7 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'maximum_addable_quantity' => $this->maximumAddable(
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
'availability' => $this->availability(
$availableStock,
$remainingUserQuota,
),
@@ -60,13 +56,10 @@ class CatalogFeaturedItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
'availability' => $this->availability(
$variantStock,
$remainingUserQuota,
'variant',
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
@@ -90,8 +83,7 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
@@ -107,8 +99,7 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
@@ -122,15 +113,13 @@ class CatalogFeaturedItemResource extends JsonResource
return $attachment?->getTemporaryUrl(1440);
}
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
{
/** @return array<string, mixed> */
private function availability(
?int $stock,
?int $remainingUserQuota,
string $scope = 'product',
): array {
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
->availability($stock, $remainingUserQuota, $scope);
}
}

View File

@@ -38,14 +38,7 @@ class CatalogItemDetailResource extends JsonResource
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'attributes' => $this->attributesData(),
'maximum_addable_quantity' => $this->when(
$selectedVariant === null,
fn () => $this->maximumAddable($this->availableStock()),
),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
),
'availability' => $this->availability($this->availableStock()),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
@@ -173,8 +166,7 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $this->availability($variantStock, 'variant'),
'values' => $values,
];
}
@@ -194,19 +186,13 @@ class CatalogItemDetailResource extends JsonResource
: $variant->inventory->availableStock();
}
private function maximumAddable(?int $stock): ?int
/** @return array<string, mixed> */
private function availability(?int $stock, string $scope = 'product'): array
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
$scope,
);
}
}

View File

@@ -28,8 +28,7 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'availability' => $this->availability($availableStock),
'variants' => $this->variants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
@@ -44,8 +43,7 @@ class CatalogSearchItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $this->availability($variantStock, 'variant'),
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
@@ -53,19 +51,13 @@ class CatalogSearchItemResource extends JsonResource
];
}
private function maximumAddable(?int $stock): ?int
/** @return array<string, mixed> */
private function availability(?int $stock, string $scope = 'product'): array
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
$scope,
);
}
}

View File

@@ -42,16 +42,75 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota);
}
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
{
/**
* @return array{
* maximum_quantity: int|null,
* restrictions: list<array{code: string, message: string, scope: string}>,
* capabilities: array{select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool}
* }
*/
public function availability(
?int $availableStock,
?int $remainingUserQuota,
string $scope = 'product',
): array {
$restrictions = [];
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
return self::USER_QUOTA_REACHED_MESSAGE;
$restrictions[] = [
'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE,
'scope' => $scope,
];
}
if ($availableStock !== null && $availableStock <= 0) {
return self::OUT_OF_STOCK_MESSAGE;
$restrictions[] = [
'code' => 'out_of_stock',
'message' => self::OUT_OF_STOCK_MESSAGE,
'scope' => $scope,
];
}
return null;
$capabilities = [
'select_variant' => true,
'change_quantity' => true,
'add_to_cart' => true,
'buy_now' => true,
];
foreach ($restrictions as $restriction) {
/** @var array<string, bool> $policy */
$policy = config("catalog.availability_policies.{$restriction['code']}", []);
foreach ($capabilities as $capability => $allowed) {
$capabilities[$capability] = $allowed && ($policy[$capability] ?? true);
}
}
return [
'maximum_quantity' => $this->maximumAddableQuantity(
$availableStock,
$remainingUserQuota,
),
'restrictions' => $restrictions,
'capabilities' => $capabilities,
];
}
/** @return array<string, mixed> */
public function purchaseLimitExceededAvailability(int $maximumQuantity, string $message): array
{
$availability = $this->availability(null, $maximumQuantity);
if ($maximumQuantity > 0) {
$availability['restrictions'][] = [
'code' => 'requested_quantity_exceeds_user_quota',
'message' => $message,
'scope' => 'product',
];
}
return $availability;
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
@@ -100,7 +101,11 @@ return Application::configure(basePath: dirname(__DIR__))
'errors' => $exception->errors(),
'catalog_item_id' => $exception->catalogItemId,
'catalog_item_name' => $exception->catalogItemName,
'maximum_addable_quantity' => $exception->maximumAddableQuantity,
'availability' => app(CatalogItemAllowanceService::class)
->purchaseLimitExceededAvailability(
$exception->maximumAddableQuantity,
$exception->getMessage(),
),
], 422);
});
$exceptions->render(function (PurchaseExpiredException $exception, Request $request) {

View File

@@ -2,4 +2,18 @@
return [
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
'availability_policies' => [
'user_quota_reached' => [
'select_variant' => false,
'change_quantity' => false,
'add_to_cart' => false,
'buy_now' => false,
],
'out_of_stock' => [
'select_variant' => false,
'change_quantity' => false,
'add_to_cart' => false,
'buy_now' => false,
],
],
];