feat(purchase): update direct item handling to support multiple items in checkout
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user