startDirectCheckout( $tenant, $userId, $purchaseData, $directItem, ); } if ($cartId === null) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.source_required'), ]); } return $this->startCartCheckout( $tenant, $userId, $purchaseData, $cartId, ); }); } public function completePurchase(Purchase $purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ($purchase->payment_method === null) { throw ValidationException::withMessages([ 'payment_method' => __('api.purchase.payment_method_required'), ]); } if (in_array($purchase->status, [ Purchase::STATUS_PAID, Purchase::STATUS_IN_REVIEW, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, ], true)) { return $this->loadPurchase($purchase); } $purchase->update([ 'status' => Purchase::STATUS_PENDING_PAYMENT, 'total' => $purchase->calculateCurrentTotalAmount(), ]); return $this->loadPurchase($purchase); }); } public function submitForReview(Purchase $purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if (in_array($purchase->status, [ Purchase::STATUS_IN_REVIEW, Purchase::STATUS_PAID, ], true)) { return $this->loadPurchase($purchase); } if ( $purchase->status !== Purchase::STATUS_PENDING_PAYMENT || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_available_for_review'), ]); } $purchase->update([ 'status' => Purchase::STATUS_IN_REVIEW, 'expires_at' => null, ]); return $this->loadPurchase($purchase); }); } /** * @param array $customerData */ public function updateCustomerData(Purchase $purchase, array $customerData): Purchase { return DB::transaction(function () use ($purchase, $customerData): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ( ! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true) || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_editable'), ]); } $purchase->update($customerData); return $this->loadPurchase($purchase); }); } public function updateItemQuantity( Purchase $purchase, PurchaseItem $purchaseItem, int $quantity, ): Purchase { return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ( $purchase->status !== Purchase::STATUS_CREATED || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_editable'), ]); } /** @var PurchaseItem|null $purchaseItem */ $purchaseItem = $purchase->items() ->whereKey($purchaseItem->getKey()) ->lockForUpdate() ->first(); if ($purchaseItem === null) { throw new NotFoundHttpException('Purchase item not found.'); } if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { throw ValidationException::withMessages([ 'item' => __('api.purchase.item_not_editable'), ]); } $currentQuantity = (int) $purchaseItem->cantidad; $difference = $quantity - $currentQuantity; if ($difference !== 0) { $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem); try { if ($difference > 0) { $this->catalogInventoryService->reserve($selection, $difference); } else { $this->catalogInventoryService->release($selection, abs($difference)); } } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'quantity' => __('api.purchase.insufficient_stock'), ]); } $purchaseItem->update([ 'cantidad' => $quantity, 'total' => (float) $purchaseItem->precio_unitario * $quantity, ]); $this->syncSourceCartItemQuantity($purchase, $purchaseItem, $quantity); } $purchase->update([ 'total' => $purchase->calculateCurrentTotalAmount(), ]); return $this->loadPurchase($purchase); }); } public function prepareItemEditing(Purchase $purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ( ! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true) || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_editable'), ]); } $purchase->telepagosQr()->delete(); $purchase->update([ 'status' => Purchase::STATUS_CREATED, 'payment_method' => null, 'transfer_payer_dni' => null, 'expires_at' => now()->addMinutes( max(1, (int) config('purchase.checkout_expiration_minutes', 30)), ), ]); return $this->loadPurchase($purchase); }); } public function confirmPurchase(Purchase $purchase): void { DB::transaction(function () use ($purchase): void { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ($purchase->status === Purchase::STATUS_PAID) { return; } if (in_array($purchase->status, [ Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, ], true)) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.cannot_confirm'), ]); } $items = $purchase->items() ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) ->lockForUpdate() ->get(); foreach ($items as $item) { $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); try { $this->catalogInventoryService->commit($selection, (int) $item->cantidad); } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } $item->update([ 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, ]); } $this->finalizeSourceCart($purchase); }); } public function cancelPurchase(Purchase $purchase): Purchase { return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED); } public function expirePurchase(Purchase $purchase): Purchase { return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED); } public function expireOverduePurchases(): int { $expiredCount = 0; Purchase::query() ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->orderBy('id') ->eachById(function (Purchase $purchase) use (&$expiredCount): void { $purchase = $this->expirePurchase($purchase); if ($purchase->status === Purchase::STATUS_EXPIRED) { $expiredCount++; } }); return $expiredCount; } private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase { return DB::transaction(function () use ($purchase, $targetStatus): Purchase { /** @var Purchase $purchase */ $purchase = Purchase::query() ->lockForUpdate() ->findOrFail($purchase->getKey()); if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); } throw ValidationException::withMessages([ 'purchase' => __('api.purchase.paid_cannot_cancel'), ]); } if (in_array($purchase->status, [ Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, ], true)) { return $this->loadPurchase($purchase); } if ( $targetStatus === Purchase::STATUS_EXPIRED && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) ) { return $this->loadPurchase($purchase); } $items = $purchase->items() ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) ->lockForUpdate() ->get(); $reservationReturnedToCart = $this->restoreSourceCart($purchase); foreach ($items as $item) { if (! $reservationReturnedToCart) { $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); try { $this->catalogInventoryService->release($selection, (int) $item->cantidad); } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } } $item->update([ 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, ]); } $purchase->update([ 'status' => $targetStatus, ]); return $this->loadPurchase($purchase); }); } /** * @param array $purchaseData * @param array $directItem */ private function startDirectCheckout( 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->resolveSelection($tenant, $catalogItemId, $variantId); $availableQuantity = $this->catalogInventoryService->availableQuantity($selection); if ($availableQuantity !== null && $availableQuantity < $quantity) { throw ValidationException::withMessages([ 'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]), ]); } try { $this->catalogInventoryService->reserve($selection, $quantity); } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'direct_item.cantidad' => __('api.purchase.insufficient_stock'), ]); } $purchase = $this->createPurchase( $tenant, $userId, $purchaseData, $selection->getPrice() * $quantity, null, ); $cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity); $purchase->items()->createMany( $this->buildPurchaseItemsPayload(collect([$cartItem])), ); return $this->loadPurchase($purchase); } /** * @param array $purchaseData */ private function startCartCheckout( Tenant $tenant, int $userId, array $purchaseData, int $cartId, ): Purchase { $cart = $this->resolveCheckoutCart($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); $cart->setRelation('items', $cartItems); $purchase = $this->createPurchase( $tenant, $userId, $purchaseData, $cart->getTotalAmount(), $cart->getKey(), ); $purchase->items()->createMany( $this->buildPurchaseItemsPayload($cartItems), ); // PurchaseItem owns the reservation during checkout. The source cart is // kept with its owner so it can be restored if the purchase is cancelled // or expires. Only active carts participate in the identity constraint. $cart->update([ 'status' => 'checkout', 'guest_token' => null, ]); return $this->loadPurchase($purchase); } private function restoreSourceCart(Purchase $purchase): bool { if ($purchase->cart_id === null) { return false; } /** @var Cart|null $sourceCart */ $sourceCart = Cart::withTrashed() ->whereKey($purchase->cart_id) ->lockForUpdate() ->first(); if ($sourceCart === null) { return false; } /** @var Cart|null $activeCart */ $activeCart = Cart::query() ->where('tenant_codigo', $purchase->tenant_codigo) ->where('user_id', $purchase->user_id) ->where('status', 'active') ->where('id', '!=', $sourceCart->getKey()) ->lockForUpdate() ->first(); if ($activeCart !== null) { $sourceItems = $sourceCart->items()->lockForUpdate()->get(); foreach ($sourceItems as $sourceItem) { /** @var CartItem|null $activeItem */ $activeItem = $activeCart->items() ->where('catalog_item_id', $sourceItem->catalog_item_id) ->where('variant_id', $sourceItem->variant_id) ->lockForUpdate() ->first(); if ($activeItem === null) { $activeCart->items()->create([ 'catalog_item_id' => $sourceItem->catalog_item_id, 'variant_id' => $sourceItem->variant_id, 'cantidad' => $sourceItem->cantidad, ]); } else { $activeItem->increment('cantidad', (int) $sourceItem->cantidad); } } $sourceCart->update([ 'status' => 'converted', 'guest_token' => null, ]); if (! $sourceCart->trashed()) { $sourceCart->delete(); } return true; } if ($sourceCart->trashed()) { $sourceCart->restore(); } $sourceCart->update([ 'status' => 'active', 'user_id' => $purchase->user_id, 'guest_token' => null, ]); return true; } private function syncSourceCartItemQuantity( Purchase $purchase, PurchaseItem $purchaseItem, int $quantity, ): void { if ($purchase->cart_id === null) { return; } $sourceCart = Cart::withTrashed() ->whereKey($purchase->cart_id) ->lockForUpdate() ->first(); if ($sourceCart === null) { return; } $sourceCart->items() ->where('catalog_item_id', $purchaseItem->source_catalog_item_id) ->where('variant_id', $purchaseItem->source_variant_id) ->update([ 'cantidad' => $quantity, ]); } private function finalizeSourceCart(Purchase $purchase): void { if ($purchase->cart_id === null) { return; } /** @var Cart|null $sourceCart */ $sourceCart = Cart::withTrashed() ->whereKey($purchase->cart_id) ->lockForUpdate() ->first(); if ($sourceCart === null || $sourceCart->trashed()) { return; } $sourceCart->update([ 'status' => 'converted', 'guest_token' => null, ]); $sourceCart->delete(); } /** * @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, ]); } protected 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'), ]); } } } protected function resolveCheckoutCart(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 * @return array> */ protected function buildPurchaseItemsPayload(Collection $cartItems): array { return $cartItems ->map(function (CartItem $item): array { $selectedItem = $item->selectedItem(); $quantity = (int) $item->cantidad; $unitPrice = $selectedItem?->getPrice() ?? 0; $imageAttachment = $this->firstImageAttachment($item); return [ 'source_catalog_item_id' => $item->catalog_item_id, 'source_variant_id' => $item->variant_id, 'image_attachment_id' => $imageAttachment?->id, 'nombre' => $item->catalogItem->nombre, 'descripcion' => $item->catalogItem->descripcion, 'slug' => $item->catalogItem->slug, 'item_nombre' => $selectedItem->getName(), 'variant_attributes' => $item->variant === null ? [] : $this->snapshotAttributes($item->variant), 'cantidad' => $quantity, 'precio_unitario' => $unitPrice, 'discount_total' => null, 'tax_total' => null, 'total' => $unitPrice * $quantity, 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, ]; }) ->all(); } private function resolveSelection( Tenant $tenant, int $catalogItemId, ?int $variantId, ): CatalogItem|Variant { /** @var CatalogItem|null $catalogItem */ $catalogItem = CatalogItem::query() ->whereKey($catalogItemId) ->where('tenant_code', $tenant->codigo) ->lockForUpdate() ->first(); if ($catalogItem === null) { throw new NotFoundHttpException('Catalog item not found for tenant.'); } if ($catalogItem->isBundle()) { if ($variantId !== null) { throw ValidationException::withMessages([ 'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'), ]); } if (! $catalogItem->bundleComponents()->exists()) { throw ValidationException::withMessages([ 'direct_item.catalog_item_id' => __('api.cart.empty_bundle'), ]); } return $catalogItem; } if ($variantId === null) { if ($catalogItem->inventory_id === null) { throw ValidationException::withMessages([ 'direct_item.variant_id' => __('api.cart.variant_required'), ]); } $catalogItem->setRelation( 'inventory', Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(), ); return $catalogItem; } /** @var Variant|null $variant */ $variant = Variant::query() ->whereKey($variantId) ->where('catalog_item_id', $catalogItem->id) ->lockForUpdate() ->first(); if ($variant === null) { throw new NotFoundHttpException('Variant not found for catalog item.'); } $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation( 'inventory', Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(), ); return $variant; } private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant { return $this->resolveSelection( $tenant, (int) $item->source_catalog_item_id, $item->source_variant_id === null ? null : (int) $item->source_variant_id, ); } 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', ]); } private function firstImageAttachment(CartItem $item): ?Attachment { return $item->variant?->attachments->first() ?? $item->catalogItem?->attachments->first(); } /** @return array */ private function snapshotAttributes(Variant $variant): array { return $variant->definitions ->map(fn ($definition): array => [ 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), 'value' => $definition->value, ]) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) ->values() ->all(); } }