$purchaseData */ public function start(Tenant $tenant, int $userId, array $purchaseData): Purchase { return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase { /** @var Tenant $tenant */ $tenant = Tenant::query() ->lockForUpdate() ->findOrFail($tenant->getKey()); $directItems = $purchaseData['direct_items'] ?? null; $cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null; unset($purchaseData['direct_items'], $purchaseData['cart_id']); if (is_array($directItems)) { return $this->startDirectItems( $tenant, $userId, $purchaseData, $directItems, ); } if ($cartId === null) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.source_required'), ]); } return $this->startFromCart($tenant, $userId, $purchaseData, $cartId); }); } /** * @param array $purchaseData * @param array> $directItems */ private function startDirectItems( Tenant $tenant, int $userId, array $purchaseData, array $directItems, ): Purchase { $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'); return $line; }) ->sortBy(fn (array $line): string => sprintf( '%020d:%020d', $line['catalog_item_id'], $line['variant_id'] ?? 0, )) ->values(); $resolvedLines = $lines->map(function (array $line) use ($tenant): array { $selection = $this->selections->resolve( $tenant, $line['catalog_item_id'], $line['variant_id'], $line['field'], ); return [ ...$line, 'selection' => $selection, 'catalog_item' => $selection instanceof Variant ? $selection->catalogItem : $selection, ]; }); $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, (float) $resolvedLines->sum( fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], ), null, ); $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($directCartItems), ); return $this->loadPurchase($purchase); } /** @param array $purchaseData */ private function startFromCart( Tenant $tenant, int $userId, array $purchaseData, int $cartId, ): Purchase { $cart = $this->resolveCart($tenant, $userId, $cartId); $cartItems = $cart->items()->lockForUpdate()->get(); if ($cartItems->isEmpty()) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.empty_cart'), ]); } $this->loadCartItems($cartItems); $this->verifyTenantItems($tenant, $cartItems); $this->assertCartPurchaseLimits($tenant, $userId, $cartItems); $cart->setRelation('items', $cartItems); $purchase = $this->createPurchase( $tenant, $userId, $purchaseData, $cart->getTotalAmount(), $cart->getKey(), ); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); // The purchase owns the reservation until checkout finishes. The cart is // retained so it can be restored if the purchase is cancelled or expires. $cart->update([ 'status' => 'checkout', 'guest_token' => null, ]); return $this->loadPurchase($purchase); } private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart { /** @var Cart|null $cart */ $cart = Cart::query()->lockForUpdate()->find($cartId); if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) { throw new NotFoundHttpException('Cart not found for tenant.'); } if ($cart->status !== 'active') { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.inactive_cart'), ]); } return $cart; } /** @param Collection $cartItems */ private function verifyTenantItems(Tenant $tenant, Collection $cartItems): void { foreach ($cartItems as $item) { if ($item->selectedItem() === null) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.catalog_item_missing'), ]); } if ($item->catalogItem?->tenant_code !== $tenant->codigo) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.catalog_item_wrong_tenant'), ]); } } } /** @param Collection $cartItems */ private function assertCartPurchaseLimits( Tenant $tenant, int $userId, Collection $cartItems, ): void { $quantities = $cartItems ->groupBy('catalog_item_id') ->map(fn (Collection $items): int => (int) $items->sum('cantidad')) ->sortKeys(); $catalogItems = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->whereKey($quantities->keys()) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); foreach ($quantities as $catalogItemId => $quantity) { /** @var CatalogItem $catalogItem */ $catalogItem = $catalogItems->get($catalogItemId); $this->purchaseLimits->assertCanPurchase( $catalogItem, $userId, $quantity, field: 'cart_id', ); } } /** @param array $purchaseData */ private function createPurchase( Tenant $tenant, int $userId, array $purchaseData, float $total, ?int $cartId, ): Purchase { return Purchase::query()->create([ ...$purchaseData, 'cart_id' => $cartId, 'tenant_codigo' => $tenant->codigo, 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, 'payment_method' => null, 'expires_at' => now()->addMinutes( max(1, (int) config('purchase.checkout_expiration_minutes', 30)), ), 'total' => $total, ]); } private function makeDirectCartItem( CatalogItem|Variant $selection, int $catalogItemId, ?int $variantId, int $quantity, ): CartItem { $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; $catalogItem->loadMissing(['inventory', 'attachments']); if ($selection instanceof Variant) { $selection->loadMissing([ 'inventory', 'attachments', 'catalogItem', 'definitions.itemAttribute.attribute', ]); } $item = new CartItem([ 'catalog_item_id' => $catalogItemId, 'variant_id' => $variantId, 'cantidad' => $quantity, ]); $item->setRelation('catalogItem', $catalogItem); $item->setRelation('variant', $selection instanceof Variant ? $selection : null); return $item; } /** @param Collection $cartItems */ private function loadCartItems(Collection $cartItems): void { $cartItems->load([ 'catalogItem.inventory', 'catalogItem.attachments', 'variant.inventory', 'variant.attachments', 'variant.catalogItem', 'variant.definitions.itemAttribute.attribute', 'variant.eventDates', 'variant.eventDate', ]); } private function loadPurchase(Purchase $purchase): Purchase { return $purchase->load(['items.imageAttachment']); } }