From 5c0b3503b70c301cf2f198e5223a7b79e173c0fc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 16:43:59 -0300 Subject: [PATCH] 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', [