From 8537d2ed0aae8912a4bbca054413914887542328 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 16:43:51 -0300 Subject: [PATCH 1/2] feat(catalog): implement maximum addable quantity logic and user quota sharing across variants --- app/Domains/Cart/Models/Cart.php | 1 + .../Catalog/Controllers/CatalogController.php | 66 ++++++++++------ .../Resources/CatalogFeaturedItemResource.php | 18 +++++ .../Resources/CatalogItemDetailResource.php | 14 ++++ .../Resources/CatalogSearchItemResource.php | 15 ++++ .../Services/CatalogItemAllowanceService.php | 40 ++++++++++ .../Catalog/Services/FeaturedGroupService.php | 8 +- .../Checkout/StartCheckoutService.php | 4 +- .../Services/UserPurchaseLimitService.php | 78 ++++++++++++++++++- .../Feature/Catalog/CatalogControllerTest.php | 42 ++++++++++ 10 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 app/Domains/Catalog/Services/CatalogItemAllowanceService.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 6032b94..4c344a4 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -369,6 +369,7 @@ class Cart extends Model $this->user_id, $cartQuantity, $excludedPurchaseId, + $this->getKey(), field: 'cantidad', ); } diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 9d75e12..e949d00 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -17,17 +17,20 @@ use App\Domains\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Catalog\Resources\CatalogItemResource; use App\Domains\Catalog\Resources\CatalogSearchItemResource; use App\Domains\Catalog\Resources\CatalogVariantOptionsResource; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\FeaturedGroupService; use App\Domains\Catalog\Services\VariantSelectionService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Auth; class CatalogController extends Controller { - public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse + public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse { $featuredGroups = FeaturedGroup::query() ->where('tenant_code', $tenant->codigo) @@ -37,7 +40,7 @@ class CatalogController extends Controller return response()->json($featuredGroups->map( fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource( $featuredGroup, - $featuredGroupService->itemsResponse($featuredGroup, 1), + $featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)), ))->resolve() )); } @@ -46,15 +49,17 @@ class CatalogController extends Controller SearchCatalogItemsRequest $request, Tenant $tenant, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): AnonymousResourceCollection { - return CatalogSearchItemResource::collection( - $catalogService->search( - $tenant, - $request->validated('q'), - $tenant->search_items_per_page, - (int) $request->validated('page', 1), - ) + $items = $catalogService->search( + $tenant, + $request->validated('q'), + $tenant->search_items_per_page, + (int) $request->validated('page', 1), ); + $allowances->attach($items->getCollection(), $this->userId($request)); + + return CatalogSearchItemResource::collection($items); } public function category( @@ -62,17 +67,19 @@ class CatalogController extends Controller Tenant $tenant, Category $category, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): AnonymousResourceCollection { abort_unless($category->tenant_code === $tenant->codigo, 404); - return CatalogSearchItemResource::collection( - $catalogService->categoryItems( - $tenant, - $category, - $tenant->search_items_per_page, - (int) $request->validated('page', 1), - ) - )->additional([ + $items = $catalogService->categoryItems( + $tenant, + $category, + $tenant->search_items_per_page, + (int) $request->validated('page', 1), + ); + $allowances->attach($items->getCollection(), $this->userId($request)); + + return CatalogSearchItemResource::collection($items)->additional([ 'category' => [ 'id' => $category->id, 'nombre' => $category->nombre, @@ -93,7 +100,11 @@ class CatalogController extends Controller $page = (int) $request->validated('page', 1); - return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page)); + return response()->json($featuredGroupService->itemsResponse( + $featuredGroup, + $page, + $this->userId($request), + )); } public function show( @@ -101,17 +112,19 @@ class CatalogController extends Controller Tenant $tenant, CatalogItem $catalogItem, CatalogService $catalogService, + CatalogItemAllowanceService $allowances, ): CatalogItemDetailResource { abort_unless($catalogItem->tenant_code === $tenant->codigo, 404); $variantId = $request->validated('variant_id'); - return CatalogItemDetailResource::make( - $catalogService->getDetail( - $catalogItem, - $variantId === null ? null : (int) $variantId, - ) + $item = $catalogService->getDetail( + $catalogItem, + $variantId === null ? null : (int) $variantId, ); + $allowances->attach(collect([$item]), $this->userId($request)); + + return CatalogItemDetailResource::make($item); } public function variantOptions( @@ -164,4 +177,11 @@ class CatalogController extends Controller ->response() ->setStatusCode(201); } + + private function userId(Request $request): ?int + { + $userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id(); + + return $userId === null ? null : (int) $userId; + } } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 7920780..0845d69 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,6 +20,7 @@ class CatalogFeaturedItemResource extends JsonResource $catalogItem = $this->resource; /** @var FeaturedGroup $featuredGroup */ $featuredGroup = $catalogItem->getRelation('featuredGroup'); + $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) { return $this->columnWithImageData($catalogItem); @@ -35,6 +37,10 @@ class CatalogFeaturedItemResource extends JsonResource 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, 'stock_tecnico' => $catalogItem->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $catalogItem->availableStock(), + $remainingUserQuota, + ), 'variants' => $catalogItem->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, @@ -47,6 +53,12 @@ class CatalogFeaturedItemResource extends JsonResource 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + $remainingUserQuota, + ), 'values' => $variant->selectorOptions($catalogItem->itemAttributes), ]) ->values(), @@ -89,4 +101,10 @@ class CatalogFeaturedItemResource extends JsonResource return $attachment?->getTemporaryUrl(1440); } + + private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int + { + return app(CatalogItemAllowanceService::class) + ->maximumAddableQuantity($stock, $remainingUserQuota); + } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index f89422f..8f7cf7f 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Shared\Enums\FieldType; use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; @@ -41,6 +42,10 @@ class CatalogItemDetailResource extends JsonResource $selectedVariant === null, fn () => $this->availableStock(), ), + 'maximum_addable_quantity' => $this->when( + $selectedVariant === null, + fn () => $this->maximumAddable($this->availableStock()), + ), 'images' => $this->when( $selectedVariant === null, fn () => $this->imageUrls($this->attachments), @@ -168,6 +173,7 @@ class CatalogItemDetailResource extends JsonResource 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->variantStock($variant), + 'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)), 'values' => $values, ]; } @@ -186,4 +192,12 @@ class CatalogItemDetailResource extends JsonResource ? null : $variant->inventory->availableStock(); } + + private function maximumAddable(?int $stock): ?int + { + return app(CatalogItemAllowanceService::class)->maximumAddableQuantity( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 4c6cd72..c75fafb 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogItemAllowanceService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,6 +28,7 @@ class CatalogSearchItemResource extends JsonResource 'precio' => $this->precio, 'image' => $attachment?->getTemporaryUrl(1440), 'stock_tecnico' => $this->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()), 'variants' => $this->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, @@ -39,9 +41,22 @@ class CatalogSearchItemResource extends JsonResource 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(), + 'maximum_addable_quantity' => $this->maximumAddable( + $this->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(), + ), 'values' => $variant->selectorOptions($this->itemAttributes), ]) ->values(), ]; } + + private function maximumAddable(?int $stock): ?int + { + return app(CatalogItemAllowanceService::class)->maximumAddableQuantity( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php new file mode 100644 index 0000000..6070c55 --- /dev/null +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -0,0 +1,40 @@ + $catalogItems */ + public function attach(Collection $catalogItems, ?int $userId): void + { + $remaining = $this->purchaseLimits->remainingByCatalogItem($catalogItems, $userId); + + foreach ($catalogItems as $catalogItem) { + $catalogItem->setAttribute( + 'remaining_user_quota', + $remaining->get($catalogItem->getKey()), + ); + } + } + + public function maximumAddableQuantity(?int $availableStock, ?int $remainingUserQuota): ?int + { + if ($availableStock === null) { + return $remainingUserQuota; + } + + if ($remainingUserQuota === null) { + return $availableStock; + } + + return min($availableStock, $remainingUserQuota); + } +} diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 970e063..7f9258e 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -15,7 +15,11 @@ class FeaturedGroupService private const ITEMS_PER_PAGE = 12; /** @return array */ - public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array + public function __construct( + private readonly CatalogItemAllowanceService $allowances, + ) {} + + public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array { if ($featuredGroup->group_layout !== GroupLayout::Paginated) { $query = $this->itemsQuery($featuredGroup); @@ -26,12 +30,14 @@ class FeaturedGroupService $items = $query->get(); $this->attachGroup($items, $featuredGroup); + $this->allowances->attach($items, $userId); return CatalogFeaturedItemResource::collection($items)->resolve(); } $paginator = $this->paginateItems($featuredGroup, $page); $this->attachGroup($paginator->getCollection(), $featuredGroup); + $this->allowances->attach($paginator->getCollection(), $userId); return CatalogFeaturedItemResource::collection($paginator) ->response() diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 9e75ce1..87a0b59 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -243,7 +243,7 @@ class StartCheckoutService $this->loadCartItems($cartItems); $this->verifyTenantItems($tenant, $cartItems); - $this->assertCartPurchaseLimits($tenant, $userId, $cartItems); + $this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey()); $cart->setRelation('items', $cartItems); $purchase = $this->createPurchase( @@ -312,6 +312,7 @@ class StartCheckoutService Tenant $tenant, int $userId, Collection $cartItems, + int $cartId, ): void { $quantities = $cartItems ->groupBy('catalog_item_id') @@ -333,6 +334,7 @@ class StartCheckoutService $catalogItem, $userId, $quantity, + excludedCartId: $cartId, field: 'cart_id', ); } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index c45d096..2a1b5e5 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -6,6 +6,7 @@ use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,6 +17,7 @@ class UserPurchaseLimitService int $userId, int $requestedQuantity, ?int $excludedPurchaseId = null, + ?int $excludedCartId = null, string $field = 'quantity', ): void { DB::transaction(function () use ( @@ -23,6 +25,7 @@ class UserPurchaseLimitService $userId, $requestedQuantity, $excludedPurchaseId, + $excludedCartId, $field, ): void { /** @var CatalogItem $catalogItem */ @@ -70,11 +73,84 @@ class UserPurchaseLimitService }) ->sum('cantidad'); - if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) { + $reservedCartQuantity = (int) CartItem::query() + ->where('catalog_item_id', $catalogItem->getKey()) + ->whereHas('cart', fn ($query) => $query + ->where('user_id', $userId) + ->where('status', 'active') + ->when( + $excludedCartId !== null, + fn ($query) => $query->whereKeyNot($excludedCartId), + )) + ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->sum('cantidad'); + + if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { throw ValidationException::withMessages([ $field => __('api.purchase_limit.exceeded', ['max' => $limit]), ]); } }); } + + /** + * @param Collection $catalogItems + * @return Collection + */ + public function remainingByCatalogItem(Collection $catalogItems, ?int $userId): Collection + { + $limits = $catalogItems + ->unique('id') + ->mapWithKeys(fn (CatalogItem $item): array => [$item->getKey() => $item->max_units_per_user]); + + if ($userId === null || $limits->filter(fn ($limit) => $limit !== null)->isEmpty()) { + return $limits->map(fn (): ?int => null); + } + + $ids = $limits->keys(); + $purchased = PurchaseItem::query() + ->selectRaw('source_catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('source_catalog_item_id', $ids) + ->whereHas('purchase', fn ($query) => $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_PAID, + ])) + ->groupBy('source_catalog_item_id') + ->pluck('quantity', 'source_catalog_item_id'); + + $checkout = CartItem::query() + ->selectRaw('catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('catalog_item_id', $ids) + ->whereHas('cart.purchases', fn ($query) => $query + ->where('user_id', $userId) + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->whereDoesntHave('items')) + ->groupBy('catalog_item_id') + ->pluck('quantity', 'catalog_item_id'); + + $reserved = CartItem::query() + ->selectRaw('catalog_item_id, SUM(cantidad) AS quantity') + ->whereIn('catalog_item_id', $ids) + ->whereHas('cart', fn ($query) => $query + ->where('user_id', $userId) + ->where('status', 'active')) + ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->groupBy('catalog_item_id') + ->pluck('quantity', 'catalog_item_id'); + + return $limits->map(function (?int $limit, int $catalogItemId) use ($purchased, $checkout, $reserved): ?int { + if ($limit === null) { + return null; + } + + $used = (int) ($purchased[$catalogItemId] ?? 0) + + (int) ($checkout[$catalogItemId] ?? 0) + + (int) ($reserved[$catalogItemId] ?? 0); + + return max(0, $limit - $used); + }); + } } diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 38ffe48..29c8b96 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; @@ -73,13 +75,53 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.stock_tecnico', 7) ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4) + ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3) + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) ->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.stock_tecnico', 8) + ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonCount(0, '1.items.data.0.variants'); } + public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void + { + $tenant = $this->createTenant('catalog-allowance'); + $group = $this->createGroup( + $tenant, + ProductLayout::ColumnWithCart, + 'Allowances', + groupLayout: GroupLayout::Simple, + ); + $user = User::factory()->create(); + $item = $this->createItem($tenant, 'Limited variants'); + $item->update(['max_units_per_user' => 5]); + $firstVariant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, + ]); + $secondVariant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, + ]); + $group->featuredItems()->create(['catalog_item_id' => $item->id]); + + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($item->id, $firstVariant->id, 2); + $cart->addItem($item->id, $secondVariant->id, 1); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 8) + ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2) + ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 9) + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2); + } + public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void { $tenant = $this->createTenant('catalog-available-variants'); From 5c0b3503b70c301cf2f198e5223a7b79e173c0fc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 16:43:59 -0300 Subject: [PATCH 2/2] feat(purchase): implement purchase limit enforcement and custom exception handling --- app/Domains/Cart/Models/Cart.php | 19 ++++++++++-- .../Resources/CatalogFeaturedItemResource.php | 4 --- .../Resources/CatalogItemDetailResource.php | 5 --- .../Resources/CatalogSearchItemResource.php | 4 --- .../PurchaseLimitExceededException.php | 31 +++++++++++++++++++ .../Checkout/StartCheckoutService.php | 7 +++++ .../Services/UserPurchaseLimitService.php | 23 +++++++++++--- bootstrap/app.php | 15 +++++++++ lang/en/api.php | 2 +- lang/es/api.php | 2 +- tests/Feature/Cart/CartControllerTest.php | 10 +++++- .../Feature/Catalog/BundleCatalogItemTest.php | 3 +- .../Feature/Catalog/CatalogControllerTest.php | 16 +++++----- .../CatalogItemDetailControllerTest.php | 18 ++++++----- tests/Feature/Purchase/StorePurchaseTest.php | 6 +++- 15 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 app/Domains/Purchase/Exceptions/PurchaseLimitExceededException.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 4c344a4..4653370 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -103,9 +103,14 @@ class Cart extends Model $cartQuantity = (int) $this->items() ->where('catalog_item_id', $catalogItemId) ->sum('cantidad'); - $this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity); $inventoryService = app(CatalogInventoryService::class); $availableQuantity = $inventoryService->availableQuantity($selectedItem); + $this->assertUserPurchaseLimit( + $selectedItem, + $cartQuantity + $quantity, + heldQuantity: $cartQuantity, + maximumAddableCeiling: $availableQuantity, + ); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ @@ -181,10 +186,13 @@ class Cart extends Model ->where('catalog_item_id', $item->catalog_item_id) ->whereKeyNot($item->getKey()) ->sum('cantidad'); + $nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection); $this->assertUserPurchaseLimit( $nextSelection, $otherVariantsQuantity + $quantity, $excludedPurchaseId, + $otherVariantsQuantity + $item->cantidad, + $nextAvailableQuantity, ); app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad); @@ -222,6 +230,7 @@ class Cart extends Model } $delta = $quantity - $item->cantidad; + $availableQuantity = $inventoryService->availableQuantity($currentSelection); if ($delta > 0) { $otherVariantsQuantity = (int) $this->items() @@ -232,11 +241,11 @@ class Cart extends Model $currentSelection, $otherVariantsQuantity + $quantity, $excludedPurchaseId, + $otherVariantsQuantity + $item->cantidad, + $availableQuantity, ); } - $availableQuantity = $inventoryService->availableQuantity($currentSelection); - if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { $maxAvailable = $availableQuantity + $item->cantidad; throw ValidationException::withMessages([ @@ -355,6 +364,8 @@ class Cart extends Model CatalogItem|Variant $selectedItem, int $cartQuantity, ?int $excludedPurchaseId = null, + int $heldQuantity = 0, + ?int $maximumAddableCeiling = null, ): void { if ($this->user_id === null) { return; @@ -370,6 +381,8 @@ class Cart extends Model $cartQuantity, $excludedPurchaseId, $this->getKey(), + $heldQuantity, + $maximumAddableCeiling, field: 'cantidad', ); } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 0845d69..1951d71 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -36,7 +36,6 @@ class CatalogFeaturedItemResource extends JsonResource 'nombre' => $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, - 'stock_tecnico' => $catalogItem->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable( $catalogItem->availableStock(), $remainingUserQuota, @@ -50,9 +49,6 @@ 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, '.', ''), - 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited - ? null - : $variant->inventory->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable( $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 8f7cf7f..7f51d93 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -38,10 +38,6 @@ class CatalogItemDetailResource extends JsonResource 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, 'attributes' => $this->attributesData(), - 'stock_tecnico' => $this->when( - $selectedVariant === null, - fn () => $this->availableStock(), - ), 'maximum_addable_quantity' => $this->when( $selectedVariant === null, fn () => $this->maximumAddable($this->availableStock()), @@ -172,7 +168,6 @@ 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, '.', ''), - 'stock_tecnico' => $this->variantStock($variant), 'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)), 'values' => $values, ]; diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index c75fafb..14d1103 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -27,7 +27,6 @@ class CatalogSearchItemResource extends JsonResource 'descripcion' => $this->descripcion, 'precio' => $this->precio, 'image' => $attachment?->getTemporaryUrl(1440), - 'stock_tecnico' => $this->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()), 'variants' => $this->visibleVariants() ->map(fn (Variant $variant): array => [ @@ -38,9 +37,6 @@ 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, '.', ''), - 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited - ? null - : $variant->inventory?->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable( $this->inventory_policy === InventoryPolicy::Unlimited ? null diff --git a/app/Domains/Purchase/Exceptions/PurchaseLimitExceededException.php b/app/Domains/Purchase/Exceptions/PurchaseLimitExceededException.php new file mode 100644 index 0000000..f767db0 --- /dev/null +++ b/app/Domains/Purchase/Exceptions/PurchaseLimitExceededException.php @@ -0,0 +1,31 @@ +catalogItemId = (int) $catalogItem->getKey(); + $this->catalogItemName = $catalogItem->nombre; + $this->maximumAddableQuantity = $maximumAddableQuantity; + + parent::__construct(validator([], [])); + + $message = trans_choice('api.purchase_limit.exceeded', $maximumAddableQuantity, [ + 'max' => $maximumAddableQuantity, + 'product' => $this->catalogItemName, + ]); + $this->message = $message; + $this->validator->errors()->add($field, $message); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 87a0b59..fa906d6 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -120,10 +120,16 @@ class StartCheckoutService ->each(function (Collection $catalogLines) use ($userId): void { /** @var CatalogItem $catalogItem */ $catalogItem = $catalogLines->first()['catalog_item']; + $availableQuantities = $catalogLines + ->map(fn (array $line): ?int => $this->inventory->availableQuantity($line['selection'])); + $maximumAddableCeiling = $availableQuantities->contains(null) + ? null + : (int) $availableQuantities->sum(); $this->purchaseLimits->assertCanPurchase( $catalogItem, $userId, (int) $catalogLines->sum('quantity'), + maximumAddableCeiling: $maximumAddableCeiling, field: 'direct_items', ); }); @@ -335,6 +341,7 @@ class StartCheckoutService $userId, $quantity, excludedCartId: $cartId, + heldQuantity: $quantity, field: 'cart_id', ); } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 2a1b5e5..6baa6fb 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -4,11 +4,11 @@ namespace App\Domains\Purchase\Services; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class UserPurchaseLimitService { @@ -18,6 +18,8 @@ class UserPurchaseLimitService int $requestedQuantity, ?int $excludedPurchaseId = null, ?int $excludedCartId = null, + int $heldQuantity = 0, + ?int $maximumAddableCeiling = null, string $field = 'quantity', ): void { DB::transaction(function () use ( @@ -26,6 +28,8 @@ class UserPurchaseLimitService $requestedQuantity, $excludedPurchaseId, $excludedCartId, + $heldQuantity, + $maximumAddableCeiling, $field, ): void { /** @var CatalogItem $catalogItem */ @@ -86,9 +90,20 @@ class UserPurchaseLimitService ->sum('cantidad'); if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { - throw ValidationException::withMessages([ - $field => __('api.purchase_limit.exceeded', ['max' => $limit]), - ]); + $remainingQuota = max( + 0, + $limit - $purchasedQuantity - $checkoutQuantity - $reservedCartQuantity, + ); + + $maximumAddableQuantity = max(0, $remainingQuota - $heldQuantity); + + throw new PurchaseLimitExceededException( + $catalogItem, + $maximumAddableCeiling === null + ? $maximumAddableQuantity + : min($maximumAddableQuantity, $maximumAddableCeiling), + $field, + ); } }); } diff --git a/bootstrap/app.php b/bootstrap/app.php index c4bafaf..da41ac0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Purchase\Exceptions\InsufficientStockException; +use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Http\Middleware\EnsureAdminAppTenant; use App\Http\Middleware\EnsureScannerTenant; @@ -87,6 +88,20 @@ return Application::configure(basePath: dirname(__DIR__)) 'unavailable_items' => $exception->unavailableItems, ], 422); }); + $exceptions->render(function (PurchaseLimitExceededException $exception, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + return response()->json([ + 'code' => 'purchase.limit_exceeded', + 'message' => $exception->getMessage(), + 'errors' => $exception->errors(), + 'catalog_item_id' => $exception->catalogItemId, + 'catalog_item_name' => $exception->catalogItemName, + 'maximum_addable_quantity' => $exception->maximumAddableQuantity, + ], 422); + }); $exceptions->render(function (ModelNotFoundException $exception, Request $request) { if (! $request->is('api/*')) { return null; diff --git a/lang/en/api.php b/lang/en/api.php index 2061d37..aa37385 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -58,7 +58,7 @@ return [ 'not_available_for_review' => 'The purchase is no longer available for review.', ], 'purchase_limit' => [ - 'exceeded' => 'You can purchase up to :max units of this product.', + 'exceeded' => '{0} You cannot add more units of “:product”.|{1} You can add up to :max more unit of “:product”.|[2,*] You can add up to :max more units of “:product”.', ], 'ticket' => [ 'not_available' => 'One or more tickets are not available.', diff --git a/lang/es/api.php b/lang/es/api.php index 6aa1a73..1fb1596 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -58,7 +58,7 @@ return [ 'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.", ], 'purchase_limit' => [ - 'exceeded' => 'Podés comprar hasta :max unidades de este producto.', + 'exceeded' => '{0} No podés agregar más unidades de “:product”.|{1} Podés agregar hasta :max unidad más de “:product”.|[2,*] Podés agregar hasta :max unidades más de “:product”.', ], 'ticket' => [ 'not_available' => 'Uno o más tickets no están disponibles.', diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 9dedcfd..d195cf7 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -241,7 +241,15 @@ class CartControllerTest extends TestCase 'cantidad' => 2, ]) ->assertUnprocessable() - ->assertJsonValidationErrors('cantidad'); + ->assertJsonValidationErrors('cantidad') + ->assertJsonPath('code', 'purchase.limit_exceeded') + ->assertJsonPath('catalog_item_id', $item->id) + ->assertJsonPath('catalog_item_name', $item->nombre) + ->assertJsonPath('maximum_addable_quantity', 1) + ->assertJsonPath( + 'message', + "Podés agregar hasta 1 unidad más de “{$item->nombre}”.", + ); $this->assertDatabaseHas('carrito_items', [ 'catalog_item_id' => $item->id, diff --git a/tests/Feature/Catalog/BundleCatalogItemTest.php b/tests/Feature/Catalog/BundleCatalogItemTest.php index cc1c288..e1e80c5 100644 --- a/tests/Feature/Catalog/BundleCatalogItemTest.php +++ b/tests/Feature/Catalog/BundleCatalogItemTest.php @@ -267,7 +267,8 @@ class BundleCatalogItemTest extends TestCase $this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}") ->assertOk() ->assertJsonPath('data.type', CatalogItemType::Bundle->value) - ->assertJsonPath('data.stock_tecnico', 4) + ->assertJsonPath('data.maximum_addable_quantity', 4) + ->assertJsonMissingPath('data.stock_tecnico') ->assertJsonCount(1, 'data.components') ->assertJsonPath('data.components.0.catalog_item_id', $component->id) ->assertJsonPath('data.components.0.variant_id', null) diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 29c8b96..0b5358b 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -22,7 +22,7 @@ class CatalogControllerTest extends TestCase { use RefreshDatabase; - public function test_row_and_column_with_cart_return_item_details_variants_and_technical_stock(): void + public function test_row_and_column_with_cart_return_item_details_variants_and_maximum_quantity(): void { $tenant = $this->createTenant('catalog-index'); $row = $this->createGroup($tenant, ProductLayout::Row, 'Row', 2); @@ -72,16 +72,14 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.nombre', 'Variants') ->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.precio', '100.00') - ->assertJsonPath('0.items.0.stock_tecnico', 7) + ->assertJsonPath('0.items.0.maximum_addable_quantity', 7) ->assertJsonCount(2, '0.items.0.variants') - ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4) ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) - ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3) ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) - ->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]) + ->assertJsonMissing(['id' => $unavailableVariant->id]) ->assertJsonPath('1.title', 'Row') - ->assertJsonPath('1.items.data.0.stock_tecnico', 8) ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) + ->assertJsonMissingPath('1.items.data.0.stock_tecnico') ->assertJsonCount(0, '1.items.data.0.variants'); } @@ -116,10 +114,10 @@ class CatalogControllerTest extends TestCase $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/{$tenant->codigo}/catalog") ->assertOk() - ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 8) ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2) - ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 9) - ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2); + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2) + ->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico') + ->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico'); } public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index e163577..3f8a06a 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -40,7 +40,8 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonPath('data.stock_tecnico', 7) + ->assertJsonPath('data.maximum_addable_quantity', 7) + ->assertJsonMissingPath('data.stock_tecnico') ->assertJsonCount(0, 'data.variants') ->assertJsonCount(1, 'data.images'); $response->assertJsonMissingPath('data.selected_variant'); @@ -71,7 +72,8 @@ class CatalogItemDetailControllerTest extends TestCase ->assertJsonCount(1, 'data.variants') ->assertJsonPath('data.variants.0.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.id', $secondVariant->id) - ->assertJsonPath('data.selected_variant.stock_tecnico', 6) + ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) + ->assertJsonMissingPath('data.selected_variant.stock_tecnico') ->assertJsonCount(1, 'data.selected_variant.images'); $response ->assertJsonMissingPath('data.stock_tecnico') @@ -125,11 +127,11 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() ->assertJsonPath('data.variants.0.id', $firstVariant->id) - ->assertJsonPath('data.variants.0.stock_tecnico', 4) + ->assertJsonPath('data.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('data.variants.0.values.size.value', 'S') ->assertJsonPath('data.variants.0.values.size.label', 'Small') ->assertJsonPath('data.variants.1.id', $secondVariant->id) - ->assertJsonPath('data.variants.1.stock_tecnico', 7) + ->assertJsonPath('data.variants.1.maximum_addable_quantity', 7) ->assertJsonPath('data.variants.1.values.size.value', 'M') ->assertJsonPath('data.variants.1.values.size.label', 'Medium') ->assertJsonPath('data.attributes.0.codigo', 'size') @@ -137,7 +139,7 @@ class CatalogItemDetailControllerTest extends TestCase ->assertJsonPath('data.attributes.0.options.1.value', 'M') ->assertJsonCount(2, 'data.attributes.0.options') ->assertJsonPath('data.selected_variant.id', $secondVariant->id) - ->assertJsonPath('data.selected_variant.stock_tecnico', 7) + ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 7) ->assertJsonPath('data.selected_variant.values.size.value', 'M') ->assertJsonPath('data.selected_variant.values.size.label', 'Medium') ->assertJsonCount(1, 'data.selected_variant.images'); @@ -177,9 +179,11 @@ class CatalogItemDetailControllerTest extends TestCase $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") ->assertOk() ->assertJsonPath('data.selected_variant.id', $variant->id) - ->assertJsonPath('data.selected_variant.stock_tecnico', null) + ->assertJsonPath('data.selected_variant.maximum_addable_quantity', null) ->assertJsonMissingPath('data.stock_tecnico') - ->assertJsonPath('data.variants.0.stock_tecnico', null); + ->assertJsonPath('data.variants.0.maximum_addable_quantity', null) + ->assertJsonMissingPath('data.selected_variant.stock_tecnico') + ->assertJsonMissingPath('data.variants.0.stock_tecnico'); } public function test_it_exposes_event_dates_as_a_dynamic_variant_attribute(): void diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index dc6f92d..9605bfc 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -340,7 +340,11 @@ class StorePurchaseTest extends TestCase ], ]) ->assertUnprocessable() - ->assertJsonValidationErrors('direct_items'); + ->assertJsonValidationErrors('direct_items') + ->assertJsonPath('code', 'purchase.limit_exceeded') + ->assertJsonPath('catalog_item_id', $variant->catalog_item_id) + ->assertJsonPath('catalog_item_name', $variant->catalogItem->nombre) + ->assertJsonPath('maximum_addable_quantity', 1); $this->actingAs($otherUser, 'sanctum') ->postJson('/api/tenants/sonder/compras/start-checkout', [