From f578c6e24a8950909692dfd73804349d5a82c110 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 6 Aug 2026 16:43:04 -0300 Subject: [PATCH] feat(cart): implement user purchase limit validation for cart items and add related tests --- app/Domains/Cart/Models/Cart.php | 37 +++++ .../Purchase/Services/CheckoutService.php | 36 +---- .../Services/UserPurchaseLimitService.php | 62 ++++++++ lang/en/api.php | 4 +- lang/es/api.php | 4 +- tests/Feature/Cart/CartControllerTest.php | 138 ++++++++++++++++++ 6 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 app/Domains/Purchase/Services/UserPurchaseLimitService.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index f4f1aca..56122a7 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -86,6 +87,10 @@ class Cart extends Model return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail(); $selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true); + $cartQuantity = (int) $this->items() + ->where('catalog_item_id', $catalogItemId) + ->sum('cantidad'); + $this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity); $inventoryService = app(CatalogInventoryService::class); $availableQuantity = $inventoryService->availableQuantity($selectedItem); @@ -141,6 +146,18 @@ class Cart extends Model ); $inventoryService = app(CatalogInventoryService::class); $delta = $quantity - $item->cantidad; + + if ($delta > 0) { + $otherVariantsQuantity = (int) $this->items() + ->where('catalog_item_id', $item->catalog_item_id) + ->whereKeyNot($item->getKey()) + ->sum('cantidad'); + $this->assertUserPurchaseLimit( + $selectedItem, + $otherVariantsQuantity + $quantity, + ); + } + $availableQuantity = $inventoryService->availableQuantity($selectedItem); if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { @@ -256,6 +273,26 @@ class Cart extends Model return $variant; } + private function assertUserPurchaseLimit( + CatalogItem|Variant $selectedItem, + int $cartQuantity, + ): void { + if ($this->user_id === null) { + return; + } + + $catalogItem = $selectedItem instanceof Variant + ? $selectedItem->catalogItem + : $selectedItem; + + app(UserPurchaseLimitService::class)->assertCanPurchase( + $catalogItem, + $this->user_id, + $cartQuantity, + field: 'cantidad', + ); + } + protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory { $query = Inventory::query()->whereKey($inventoryId); diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 7f0a2bb..253f405 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -21,6 +21,7 @@ class CheckoutService { public function __construct( private readonly CatalogInventoryService $catalogInventoryService, + private readonly UserPurchaseLimitService $userPurchaseLimitService, ) {} public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase @@ -709,34 +710,13 @@ class CheckoutService ?int $excludedPurchaseId = null, string $field = 'quantity', ): void { - $limit = $catalogItem->max_units_per_user; - - if ($limit === null) { - return; - } - - $purchasedQuantity = (int) PurchaseItem::query() - ->where('source_catalog_item_id', $catalogItem->getKey()) - ->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void { - $query - ->where('user_id', $userId) - ->whereIn('status', [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - Purchase::STATUS_PAID, - ]) - ->when( - $excludedPurchaseId !== null, - fn ($query) => $query->whereKeyNot($excludedPurchaseId), - ); - }) - ->sum('cantidad'); - - if ($purchasedQuantity + $requestedQuantity > $limit) { - throw ValidationException::withMessages([ - $field => __('api.purchase.max_units_per_user', ['max' => $limit]), - ]); - } + $this->userPurchaseLimitService->assertCanPurchase( + $catalogItem, + $userId, + $requestedQuantity, + $excludedPurchaseId, + $field, + ); } protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php new file mode 100644 index 0000000..8b3cc58 --- /dev/null +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -0,0 +1,62 @@ +whereKey($catalogItem->getKey()) + ->lockForUpdate() + ->firstOrFail(); + $limit = $catalogItem->max_units_per_user; + + if ($limit === null) { + return; + } + + $purchasedQuantity = (int) PurchaseItem::query() + ->where('source_catalog_item_id', $catalogItem->getKey()) + ->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void { + $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_PAID, + ]) + ->when( + $excludedPurchaseId !== null, + fn ($query) => $query->whereKeyNot($excludedPurchaseId), + ); + }) + ->sum('cantidad'); + + if ($purchasedQuantity + $requestedQuantity > $limit) { + throw ValidationException::withMessages([ + $field => __('api.purchase_limit.exceeded', ['max' => $limit]), + ]); + } + }); + } +} diff --git a/lang/en/api.php b/lang/en/api.php index b56d727..c3910da 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -42,7 +42,6 @@ return [ 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', 'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.', 'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.', - 'max_units_per_user' => 'You can purchase up to :max units of this product.', 'empty_cart' => 'The selected cart does not contain items.', 'catalog_item_missing' => 'One or more catalog items could not be loaded.', 'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.', @@ -50,6 +49,9 @@ return [ 'not_available_for_payment' => 'The purchase is no longer available for payment.', '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.', + ], 'ticket' => [ 'not_available' => 'One or more tickets are not available.', 'positive_quantity' => 'The number of tickets to generate must be greater than zero.', diff --git a/lang/es/api.php b/lang/es/api.php index bd0d990..79bda55 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -42,7 +42,6 @@ return [ 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', 'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.', 'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.', - 'max_units_per_user' => 'Podés comprar hasta :max unidades de este producto.', 'empty_cart' => 'El carrito seleccionado no contiene productos.', 'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.', 'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.', @@ -50,6 +49,9 @@ return [ 'not_available_for_payment' => 'La compra ya no está disponible para el pago.', '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.', + ], 'ticket' => [ 'not_available' => 'Uno o más tickets no están disponibles.', 'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.', diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 950c106..1a6f7f9 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -4,10 +4,13 @@ namespace Tests\Feature\Cart; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Auth\Models\User; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Schema; @@ -99,6 +102,114 @@ class CartControllerTest extends TestCase ]); } + public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 4]); + $this->createPurchaseItem($tenant, $user, $item, 1); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.cantidad', 2); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseHas('carrito_items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'reserved_stock' => 2, + ]); + } + + public function test_authenticated_cart_shares_the_purchase_limit_between_variants(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + [$item, $firstVariant] = $this->createVariantItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 3]); + $secondInventory = Inventory::query()->create(['real_stock' => 20]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $secondVariant->id, + 'cantidad' => 2, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseCount('carrito_items', 1); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 0, + ]); + } + + public function test_authenticated_cart_rejects_quantity_updates_above_the_purchase_limit(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 3]); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->patchJson('/api/tenants/acme/cart/items/'.$response->json('data.items.0.id'), [ + 'cantidad' => 4, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseHas('carrito_items', [ + 'id' => $response->json('data.items.0.id'), + 'cantidad' => 2, + ]); + } + + public function test_guest_cart_does_not_apply_a_user_purchase_limit(): void + { + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 1]); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.cantidad', 2); + } + public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void { $tenant = $this->createTenant('acme'); @@ -245,6 +356,33 @@ class CartControllerTest extends TestCase return [$item, $variant]; } + private function createPurchaseItem( + Tenant $tenant, + User $user, + CatalogItem $item, + int $quantity, + ): PurchaseItem { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => (float) $item->precio * $quantity, + ]); + + return $purchase->items()->create([ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => null, + 'nombre' => $item->nombre, + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'variant_attributes' => [], + 'cantidad' => $quantity, + 'precio_unitario' => $item->precio, + 'total' => (float) $item->precio * $quantity, + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header");