diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php new file mode 100644 index 0000000..c6df893 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -0,0 +1,90 @@ +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; + } + + public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant + { + return $this->resolve( + $tenant, + (int) $item->source_catalog_item_id, + $item->source_variant_id === null ? null : (int) $item->source_variant_id, + ); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php new file mode 100644 index 0000000..d5c2baa --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -0,0 +1,131 @@ +lockPurchase($purchase); + + if ($purchase->payment_method === null) { + throw ValidationException::withMessages([ + 'payment_method' => __('api.purchase.payment_method_required'), + ]); + } + + if ($this->isTerminal($purchase)) { + 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 { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status === Purchase::STATUS_PAID) { + 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(['expires_at' => null]); + + return $this->loadPurchase($purchase); + }); + } + + public function confirm(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): void { + $purchase = $this->lockPurchase($purchase); + + 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->selections->resolvePurchaseItem($purchase->tenant, $item); + + try { + $this->inventory->commit($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + } + + $this->sourceCart->finalize($purchase); + }); + } + + private function isTerminal(Purchase $purchase): bool + { + return in_array($purchase->status, [ + Purchase::STATUS_PAID, + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php new file mode 100644 index 0000000..9491b38 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -0,0 +1,175 @@ + $customerData */ + public function updateCustomer(Purchase $purchase, array $customerData): Purchase + { + return DB::transaction(function () use ($purchase, $customerData): Purchase { + $purchase = $this->lockPurchase($purchase); + $this->assertEditable($purchase); + + $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 { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_editable'), + ]); + } + + $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); + $difference = $quantity - (int) $purchaseItem->cantidad; + + if ($difference !== 0) { + $this->adjustReservation($purchase, $purchaseItem, $quantity, $difference); + + $purchaseItem->update([ + 'cantidad' => $quantity, + 'total' => (float) $purchaseItem->precio_unitario * $quantity, + ]); + $this->sourceCart->syncItemQuantity($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 { + $purchase = $this->lockPurchase($purchase); + $this->assertEditable($purchase); + + $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); + }); + } + + private function adjustReservation( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + int $difference, + ): void { + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem); + + try { + if ($difference > 0) { + $otherItemQuantity = (int) $purchase->items() + ->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id) + ->whereKeyNot($purchaseItem->getKey()) + ->sum('cantidad'); + $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; + + $this->purchaseLimits->assertCanPurchase( + $catalogItem, + (int) $purchase->user_id, + $otherItemQuantity + $quantity, + $purchase->getKey(), + ); + $this->inventory->reserve($selection, $difference); + } else { + $this->inventory->release($selection, abs($difference)); + } + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'quantity' => __('api.purchase.insufficient_stock'), + ]); + } + } + + private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem + { + /** @var PurchaseItem|null $lockedItem */ + $lockedItem = $purchase->items() + ->whereKey($item->getKey()) + ->lockForUpdate() + ->first(); + + if ($lockedItem === null) { + throw new NotFoundHttpException('Purchase item not found.'); + } + + if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { + throw ValidationException::withMessages([ + 'item' => __('api.purchase.item_not_editable'), + ]); + } + + return $lockedItem; + } + + private function assertEditable(Purchase $purchase): void + { + if ( + ! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + || $this->hasExpired($purchase) + ) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_editable'), + ]); + } + } + + private function hasExpired(Purchase $purchase): bool + { + return $purchase->expires_at !== null && $purchase->expires_at->isPast(); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php new file mode 100644 index 0000000..28662aa --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -0,0 +1,65 @@ + $cartItems + * @return array> + */ + public function fromCartItems(Collection $cartItems): array + { + return $cartItems + ->map(function (CartItem $item): array { + $selectedItem = $item->selectedItem(); + $quantity = (int) $item->cantidad; + $unitPrice = $selectedItem?->getPrice() ?? 0; + + return [ + 'source_catalog_item_id' => $item->catalog_item_id, + 'source_variant_id' => $item->variant_id, + 'image_attachment_id' => $this->firstImageAttachment($item)?->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 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(); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php new file mode 100644 index 0000000..24d8712 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -0,0 +1,129 @@ +release($purchase, Purchase::STATUS_CANCELLED); + } + + public function expire(Purchase $purchase): Purchase + { + return $this->release($purchase, Purchase::STATUS_EXPIRED); + } + + public function expireOverdue(): 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->expire($purchase); + + if ($purchase->status === Purchase::STATUS_EXPIRED) { + $expiredCount++; + } + }); + + return $expiredCount; + } + + private function release(Purchase $purchase, string $targetStatus): Purchase + { + return DB::transaction(function () use ($purchase, $targetStatus): Purchase { + $purchase = $this->lockPurchase($purchase); + + 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 ($this->isAlreadyReleased($purchase)) { + 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->sourceCart->restore($purchase); + + foreach ($items as $item) { + if (! $reservationReturnedToCart) { + $this->releaseInventory($purchase, $item); + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, + ]); + } + + $purchase->update(['status' => $targetStatus]); + + return $this->loadPurchase($purchase); + }); + } + + private function releaseInventory(Purchase $purchase, PurchaseItem $item): void + { + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + + try { + $this->inventory->release($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + } + + private function isAlreadyReleased(Purchase $purchase): bool + { + return in_array($purchase->status, [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php new file mode 100644 index 0000000..45624cd --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -0,0 +1,125 @@ +findSourceCart($purchase); + + 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) { + $this->mergeIntoActiveCart($sourceCart, $activeCart); + + $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; + } + + public function syncItemQuantity( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + ): void { + $sourceCart = $this->findSourceCart($purchase); + + 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]); + } + + public function finalize(Purchase $purchase): void + { + $sourceCart = $this->findSourceCart($purchase); + + if ($sourceCart === null || $sourceCart->trashed()) { + return; + } + + $sourceCart->update([ + 'status' => 'converted', + 'guest_token' => null, + ]); + $sourceCart->delete(); + } + + private function findSourceCart(Purchase $purchase): ?Cart + { + if ($purchase->cart_id === null) { + return null; + } + + /** @var Cart|null */ + return Cart::withTrashed() + ->whereKey($purchase->cart_id) + ->lockForUpdate() + ->first(); + } + + private function mergeIntoActiveCart(Cart $sourceCart, Cart $activeCart): void + { + $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); + } + } + } +} diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php new file mode 100644 index 0000000..cfc7a78 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -0,0 +1,289 @@ + $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']); + } +} diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 253f405..1119191 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -2,154 +2,48 @@ namespace App\Domains\Purchase\Services; -use App\Domains\Attachable\Models\Attachment; -use App\Domains\Cart\Models\Cart; -use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Models\CatalogItem; -use App\Domains\Catalog\Models\Inventory; -use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; +use App\Domains\Purchase\Services\Checkout\EditCheckoutService; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Tenant\Models\Tenant; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +/** + * Stable checkout API used by controllers, commands and integrations. + * + * Workflow details live in focused services under Services/Checkout. + */ class CheckoutService { public function __construct( - private readonly CatalogInventoryService $catalogInventoryService, - private readonly UserPurchaseLimitService $userPurchaseLimitService, + private readonly StartCheckoutService $starter, + private readonly EditCheckoutService $editor, + private readonly CompleteCheckoutService $completer, + private readonly ReleaseCheckoutService $releaser, ) {} + /** @param array $purchaseData */ public function startCheckout(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->startDirectCheckout( - $tenant, - $userId, - $purchaseData, - $directItem, - ); - } - - if ($cartId === null) { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.source_required'), - ]); - } - - return $this->startCartCheckout( - $tenant, - $userId, - $purchaseData, - $cartId, - ); - }); + return $this->starter->start($tenant, $userId, $purchaseData); } 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_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); - }); + return $this->completer->complete($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_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([ - 'expires_at' => null, - ]); - - return $this->loadPurchase($purchase); - }); + return $this->completer->submitForReview($purchase); } - /** - * @param array $customerData - */ + /** @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); - }); + return $this->editor->updateCustomer($purchase, $customerData); } public function updateItemQuantity( @@ -157,766 +51,31 @@ class CheckoutService 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) { - $otherItemQuantity = (int) $purchase->items() - ->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id) - ->whereKeyNot($purchaseItem->getKey()) - ->sum('cantidad'); - $catalogItem = $selection instanceof Variant - ? $selection->catalogItem - : $selection; - $this->assertUserPurchaseLimit( - $catalogItem, - (int) $purchase->user_id, - $otherItemQuantity + $quantity, - $purchase->getKey(), - ); - $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); - }); + return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity); } 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); - }); + return $this->editor->prepareItemEditing($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); - }); + $this->completer->confirm($purchase); } public function cancelPurchase(Purchase $purchase): Purchase { - return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED); + return $this->releaser->cancel($purchase); } public function expirePurchase(Purchase $purchase): Purchase { - return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED); + return $this->releaser->expire($purchase); } 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); - $catalogItem = $selection instanceof Variant - ? $selection->catalogItem - : $selection; - $this->assertUserPurchaseLimit( - $catalogItem, - $userId, - $quantity, - field: 'direct_item.cantidad', - ); - $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); - $this->assertCartUserPurchaseLimits($tenant, $userId, $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, - '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, - ]); - } - - 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'), - ]); - } - } - } - - /** @param Collection $cartItems */ - private function assertCartUserPurchaseLimits( - 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->assertUserPurchaseLimit( - $catalogItem, - $userId, - $quantity, - field: 'cart_id', - ); - } - } - - private function assertUserPurchaseLimit( - CatalogItem $catalogItem, - int $userId, - int $requestedQuantity, - ?int $excludedPurchaseId = null, - string $field = 'quantity', - ): void { - $this->userPurchaseLimitService->assertCanPurchase( - $catalogItem, - $userId, - $requestedQuantity, - $excludedPurchaseId, - $field, - ); - } - - 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(); + return $this->releaser->expireOverdue(); } }