feat(cart): implement user purchase limit validation for cart items and add related tests
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
62
app/Domains/Purchase/Services/UserPurchaseLimitService.php
Normal file
62
app/Domains/Purchase/Services/UserPurchaseLimitService.php
Normal 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]),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user