feat(cart): implement user purchase limit validation for cart items and add related tests

This commit is contained in:
2026-08-06 16:43:04 -03:00
parent 5ad61eb217
commit f578c6e24a
6 changed files with 251 additions and 30 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UserPurchaseLimitService
{
public function assertCanPurchase(
CatalogItem $catalogItem,
int $userId,
int $requestedQuantity,
?int $excludedPurchaseId = null,
string $field = 'quantity',
): void {
DB::transaction(function () use (
$catalogItem,
$userId,
$requestedQuantity,
$excludedPurchaseId,
$field,
): void {
/** @var CatalogItem $catalogItem */
$catalogItem = CatalogItem::query()
->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]),
]);
}
});
}
}

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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");