$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()); $directItem = $purchaseData['direct_item'] ?? null; $cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null; unset($purchaseData['direct_item'], $purchaseData['cart_id']); if (is_array($directItem)) { return $this->startDirect($tenant, $userId, $purchaseData, $directItem); } if ($cartId === null) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.source_required'), ]); } return $this->startFromCart($tenant, $userId, $purchaseData, $cartId); }); } /** * @param array $purchaseData * @param array $directItem */ private function startDirect( Tenant $tenant, int $userId, array $purchaseData, array $directItem, ): 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; $this->purchaseLimits->assertCanPurchase( $catalogItem, $userId, $quantity, field: 'direct_item.cantidad', ); $availableQuantity = $this->inventory->availableQuantity($selection); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ 'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]), ]); } try { $this->inventory->reserve($selection, $quantity); } catch (\InvalidArgumentException) { throw ValidationException::withMessages([ 'direct_item.cantidad' => __('api.purchase.insufficient_stock'), ]); } $purchase = $this->createPurchase( $tenant, $userId, $purchaseData, $selection->getPrice() * $quantity, null, ); $directCartItem = $this->makeDirectCartItem( $selection, $catalogItemId, $variantId, $quantity, ); $purchase->items()->createMany( $this->snapshots->fromCartItems(collect([$directCartItem])), ); 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, 'event_id' => $tenant->active_event_id, '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', ]); } private function loadPurchase(Purchase $purchase): Purchase { return $purchase->load(['items.imageAttachment']); } }