From 2fd6851b401ff37dc0658b4f48f5d4e320bea7b0 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 14:32:55 -0300 Subject: [PATCH] feat(catalog): expose availability policies --- .../Resources/CatalogFeaturedItemResource.php | 35 ++++------ .../Resources/CatalogItemDetailResource.php | 26 ++----- .../Resources/CatalogSearchItemResource.php | 20 ++---- .../Services/CatalogItemAllowanceService.php | 69 +++++++++++++++++-- bootstrap/app.php | 7 +- config/catalog.php | 14 ++++ 6 files changed, 108 insertions(+), 63 deletions(-) diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 4a02a78..de8f552 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -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 */ + 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); } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index d0c1090..18b0e9b 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -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 */ + 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, ); } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 3d1c2d8..da1d726 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -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 */ + 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, ); } } diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php index 26726f2..de46f3d 100644 --- a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -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, + * 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 $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 */ + 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; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 6159d8d..2189296 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ $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) { diff --git a/config/catalog.php b/config/catalog.php index 4bab7dc..f174c28 100644 --- a/config/catalog.php +++ b/config/catalog.php @@ -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, + ], + ], ];