From 5b089e71b284d1480b0e9f5b2631f5676516c93d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 12 Aug 2026 14:29:10 -0300 Subject: [PATCH] feat(purchase): update direct item handling to support multiple items in checkout --- .../Requests/StartCheckoutRequest.php | 22 +-- .../Checkout/CatalogSelectionResolver.php | 7 +- .../Checkout/StartCheckoutService.php | 133 ++++++++++---- lang/en/api.php | 2 +- lang/es/api.php | 2 +- tests/Feature/Purchase/StorePurchaseTest.php | 171 +++++++++++++++--- 6 files changed, 250 insertions(+), 87 deletions(-) diff --git a/app/Domains/Purchase/Requests/StartCheckoutRequest.php b/app/Domains/Purchase/Requests/StartCheckoutRequest.php index 4133dad..d68df2d 100644 --- a/app/Domains/Purchase/Requests/StartCheckoutRequest.php +++ b/app/Domains/Purchase/Requests/StartCheckoutRequest.php @@ -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'], diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index c6df893..db246ed 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -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'), ]); } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b5ade20..153812e 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -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 $purchaseData - * @param array $directItem + * @param array> $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); diff --git a/lang/en/api.php b/lang/en/api.php index 645f77f..c6a4a74 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -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.', diff --git a/lang/es/api.php b/lang/es/api.php index cb3031c..e5c7e76 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -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.', diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 6cbc361..b12384e 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -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',