feat(purchase): update direct item handling to support multiple items in checkout

This commit is contained in:
2026-08-12 14:29:10 -03:00
parent 45d74e166f
commit 5b089e71b2
6 changed files with 250 additions and 87 deletions

View File

@@ -19,29 +19,21 @@ class StartCheckoutRequest extends FormRequest
{
return [
'cart_id' => [
'required_without:direct_item',
Rule::prohibitedIf(fn (): bool => $this->has('direct_item')),
'required_without:direct_items',
Rule::prohibitedIf(fn (): bool => $this->has('direct_items')),
'integer',
'exists:carritos,id',
],
'direct_item' => [
'direct_items' => [
'required_without:cart_id',
Rule::prohibitedIf(fn (): bool => $this->has('cart_id')),
'array',
],
'direct_item.catalog_item_id' => [
'required_with:direct_item',
'integer',
],
'direct_item.variant_id' => [
'nullable',
'integer',
],
'direct_item.cantidad' => [
'required_with:direct_item',
'integer',
'min:1',
],
'direct_items.*' => ['required', 'array'],
'direct_items.*.catalog_item_id' => ['required', 'integer'],
'direct_items.*.variant_id' => ['nullable', 'integer'],
'direct_items.*.cantidad' => ['required', 'integer', 'min:1'],
'dni' => ['prohibited'],
'telefono' => ['prohibited'],
'nombre_apellido' => ['prohibited'],

View File

@@ -16,6 +16,7 @@ class CatalogSelectionResolver
Tenant $tenant,
int $catalogItemId,
?int $variantId,
string $fieldPrefix = 'direct_items',
): CatalogItem|Variant {
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
@@ -31,13 +32,13 @@ class CatalogSelectionResolver
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
"{$fieldPrefix}.variant_id" => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
"{$fieldPrefix}.catalog_item_id" => __('api.cart.empty_bundle'),
]);
}
@@ -47,7 +48,7 @@ class CatalogSelectionResolver
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.variant_required'),
"{$fieldPrefix}.variant_id" => __('api.cart.variant_required'),
]);
}

View File

@@ -33,12 +33,17 @@ class StartCheckoutService
->lockForUpdate()
->findOrFail($tenant->getKey());
$directItem = $purchaseData['direct_item'] ?? null;
$directItems = $purchaseData['direct_items'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
unset($purchaseData['direct_items'], $purchaseData['cart_id']);
if (is_array($directItem)) {
return $this->startDirect($tenant, $userId, $purchaseData, $directItem);
if (is_array($directItems)) {
return $this->startDirectItems(
$tenant,
$userId,
$purchaseData,
$directItems,
);
}
if ($cartId === null) {
@@ -53,58 +58,110 @@ class StartCheckoutService
/**
* @param array<string, mixed> $purchaseData
* @param array<string, mixed> $directItem
* @param array<int, array<string, mixed>> $directItems
*/
private function startDirect(
private function startDirectItems(
Tenant $tenant,
int $userId,
array $purchaseData,
array $directItem,
array $directItems,
): Purchase {
$catalogItemId = (int) $directItem['catalog_item_id'];
$variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null;
$quantity = (int) $directItem['cantidad'];
$selection = $this->selections->resolve($tenant, $catalogItemId, $variantId);
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$lines = collect(array_values($directItems))
->map(function (array $item, int $index): array {
return [
'catalog_item_id' => (int) $item['catalog_item_id'],
'variant_id' => isset($item['variant_id']) ? (int) $item['variant_id'] : null,
'quantity' => (int) $item['cantidad'],
'field' => "direct_items.{$index}",
];
})
->groupBy(fn (array $line): string => sprintf(
'%d:%s',
$line['catalog_item_id'],
$line['variant_id'] === null ? 'none' : (string) $line['variant_id'],
))
->map(function (Collection $duplicateLines): array {
$line = $duplicateLines->first();
$line['quantity'] = (int) $duplicateLines->sum('quantity');
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
$quantity,
field: 'direct_item.cantidad',
);
return $line;
})
->sortBy(fn (array $line): string => sprintf(
'%020d:%020d',
$line['catalog_item_id'],
$line['variant_id'] ?? 0,
))
->values();
$availableQuantity = $this->inventory->availableQuantity($selection);
$resolvedLines = $lines->map(function (array $line) use ($tenant): array {
$selection = $this->selections->resolve(
$tenant,
$line['catalog_item_id'],
$line['variant_id'],
$line['field'],
);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
return [
...$line,
'selection' => $selection,
'catalog_item' => $selection instanceof Variant
? $selection->catalogItem
: $selection,
];
});
try {
$this->inventory->reserve($selection, $quantity);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
$resolvedLines
->groupBy(fn (array $line): int => $line['catalog_item']->getKey())
->each(function (Collection $catalogLines) use ($userId): void {
/** @var CatalogItem $catalogItem */
$catalogItem = $catalogLines->first()['catalog_item'];
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
(int) $catalogLines->sum('quantity'),
field: 'direct_items',
);
});
foreach ($resolvedLines as $line) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
if ($availableQuantity !== null && $availableQuantity < $line['quantity']) {
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => __('api.purchase.direct_items_max_stock', [
'max' => $availableQuantity,
]),
]);
}
try {
$this->inventory->reserve($line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => __('api.purchase.insufficient_stock'),
]);
}
}
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$selection->getPrice() * $quantity,
(float) $resolvedLines->sum(
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
),
null,
);
$directCartItem = $this->makeDirectCartItem(
$selection,
$catalogItemId,
$variantId,
$quantity,
);
$directCartItems = $resolvedLines->map(fn (array $line): CartItem => $this->makeDirectCartItem(
$line['selection'],
$line['catalog_item_id'],
$line['variant_id'],
$line['quantity'],
));
$purchase->items()->createMany(
$this->snapshots->fromCartItems(collect([$directCartItem])),
$this->snapshots->fromCartItems($directCartItems),
);
return $this->loadPurchase($purchase);

View File

@@ -42,7 +42,7 @@ return [
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'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.',
'direct_items_max_stock' => 'There is not enough stock. Maximum available: :max.',
'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.',

View File

@@ -42,7 +42,7 @@ return [
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'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.',
'direct_items_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
'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.',

View File

@@ -143,10 +143,12 @@ class StorePurchaseTest extends TestCase
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
],
],
])
->assertCreated()
@@ -185,6 +187,105 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_creates_one_direct_purchase_with_multiple_variant_items(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 1, '50.00');
$secondInventory = Inventory::query()->create(['real_stock' => 1]);
$secondVariant = Variant::query()->create([
'catalog_item_id' => $firstVariant->catalog_item_id,
'inventory_id' => $secondInventory->id,
'precio' => '75.00',
]);
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_items' => [
[
'catalog_item_id' => $firstVariant->catalog_item_id,
'variant_id' => $firstVariant->id,
'cantidad' => 1,
],
[
'catalog_item_id' => $secondVariant->catalog_item_id,
'variant_id' => $secondVariant->id,
'cantidad' => 1,
],
],
])
->assertCreated()
->assertJsonPath('data.cart_id', null)
->assertJsonCount(2, 'data.items')
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
->assertJsonPath('data.total', '125.00');
$purchaseId = $response->json('data.id');
$this->assertDatabaseCount('carritos', 0);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_variant_id' => $firstVariant->id,
'cantidad' => 1,
'reservation_status' => 'active',
]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_variant_id' => $secondVariant->id,
'cantidad' => 1,
'reservation_status' => 'active',
]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 1,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 1,
]);
}
public function test_multi_item_direct_purchase_rolls_back_every_reservation_when_one_item_is_unavailable(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$availableVariant = $this->createVariantForTenant('sonder', 1, '50.00');
$unavailableInventory = Inventory::query()->create(['real_stock' => 0]);
$unavailableVariant = Variant::query()->create([
'catalog_item_id' => $availableVariant->catalog_item_id,
'inventory_id' => $unavailableInventory->id,
]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_items' => [
[
'catalog_item_id' => $availableVariant->catalog_item_id,
'variant_id' => $availableVariant->id,
'cantidad' => 1,
],
[
'catalog_item_id' => $unavailableVariant->catalog_item_id,
'variant_id' => $unavailableVariant->id,
'cantidad' => 1,
],
],
])
->assertUnprocessable()
->assertJsonValidationErrors('direct_items.1.cantidad');
$this->assertDatabaseCount('compras', 0);
$this->assertDatabaseHas('inventories', [
'id' => $availableVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $unavailableInventory->id,
'reserved_stock' => 0,
]);
}
public function test_it_enforces_the_user_purchase_limit_and_releases_it_after_cancellation(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@@ -195,10 +296,12 @@ class StorePurchaseTest extends TestCase
$firstPurchaseId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
],
],
])
->assertCreated()
@@ -206,21 +309,25 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
],
],
])
->assertUnprocessable()
->assertJsonValidationErrors('direct_item.cantidad');
->assertJsonValidationErrors('direct_items');
$this->actingAs($otherUser, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
],
],
])
->assertCreated();
@@ -231,10 +338,12 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 3,
],
],
])
->assertCreated();
@@ -361,10 +470,12 @@ class StorePurchaseTest extends TestCase
$purchaseResponse = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
],
],
])
->assertCreated()
@@ -535,10 +646,12 @@ class StorePurchaseTest extends TestCase
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'direct_item' => [
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 1,
'direct_items' => [
[
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 1,
],
],
'dni' => '987654321',
'telefono' => '+54 9 341 555-4321',