From 2bd3bf9dc1bf1c5d61dccf80a963909449275751 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:09:49 -0300 Subject: [PATCH] feat(checkout): route carts to replacement variants --- app/Domains/Cart/Models/Cart.php | 17 ++++ .../CartVariantReplacementService.php | 99 +++++++++++++++++++ .../Checkout/CatalogSelectionResolver.php | 17 ++++ .../Checkout/StartCheckoutService.php | 3 + lang/en/api.php | 3 + lang/es/api.php | 3 + 6 files changed, 142 insertions(+) create mode 100644 app/Domains/Cart/Services/CartVariantReplacementService.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 9c6ea42..afdaa79 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -400,6 +400,17 @@ class Cart extends Model ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + 'catalog_item_id' => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -430,6 +441,12 @@ class Cart extends Model throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + 'variant_id' => [__('api.cart.variant_unavailable')], + ]); + } + $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('inventory', $inventory); diff --git a/app/Domains/Cart/Services/CartVariantReplacementService.php b/app/Domains/Cart/Services/CartVariantReplacementService.php new file mode 100644 index 0000000..0dda8eb --- /dev/null +++ b/app/Domains/Cart/Services/CartVariantReplacementService.php @@ -0,0 +1,99 @@ +items() + ->whereNotNull('variant_id') + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $variant = Variant::query()->lockForUpdate()->find($item->variant_id); + + if ($variant === null) { + throw $this->unavailableVariant(); + } + + $replacement = $this->latestReplacement($variant); + + if ($replacement->is($variant)) { + if (! $variant->isSellable()) { + throw $this->unavailableVariant(); + } + + continue; + } + + if (! $replacement->isSellable()) { + throw $this->unavailableVariant(); + } + + /** @var CartItem|null $targetItem */ + $targetItem = $cart->items() + ->whereKeyNot($item->getKey()) + ->where('catalog_item_id', $item->catalog_item_id) + ->where('variant_id', $replacement->getKey()) + ->lockForUpdate() + ->first(); + + $replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0); + if ($replacement->inventory_id !== $variant->inventory_id) { + $available = $this->inventory->availableQuantity($replacement); + + if ($available !== null && $available < $replacementQuantity) { + throw $this->unavailableVariant(); + } + } + + if ($targetItem !== null) { + $targetItem->cantidad += $item->cantidad; + $targetItem->save(); + $item->delete(); + + continue; + } + + $item->update(['variant_id' => $replacement->getKey()]); + } + } + + private function latestReplacement(Variant $variant): Variant + { + $current = $variant; + $visited = []; + + while ($current->replaced_by_variant_id !== null) { + if (isset($visited[$current->getKey()])) { + throw $this->unavailableVariant(); + } + + $visited[$current->getKey()] = true; + $current = Variant::query() + ->lockForUpdate() + ->find($current->replaced_by_variant_id) + ?? throw $this->unavailableVariant(); + } + + return $current; + } + + private function unavailableVariant(): ValidationException + { + return ValidationException::withMessages([ + 'cart_id' => [__('api.cart.cart_variant_unavailable')], + ]); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index cfa9233..15e09d2 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -41,6 +41,17 @@ class CatalogSelectionResolver ]); } + if ($catalogItem->bundleComponents() + ->whereNotNull('component_variant_id') + ->whereHas('variant', fn ($query) => $query + ->whereNotNull('sales_disabled_at') + ->orWhereNotNull('replaced_by_variant_id')) + ->exists()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')], + ]); + } + return $catalogItem; } @@ -70,6 +81,12 @@ class CatalogSelectionResolver throw new NotFoundHttpException('Variant not found for catalog item.'); } + if (! $variant->isSellable()) { + throw ValidationException::withMessages([ + "{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')], + ]); + } + $variant->setRelation('catalogItem', $catalogItem); $variant->setRelation( 'inventory', diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b34fd41..71cc159 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Cart\Services\CartVariantReplacementService; use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; @@ -29,6 +30,7 @@ class StartCheckoutService private readonly InsufficientStockMessageBuilder $stockMessages, private readonly PurchaseResponseLoader $responses, private readonly PurchaseItemSnapshotFactory $snapshots, + private readonly CartVariantReplacementService $variantReplacements, ) {} /** @param array $purchaseData */ @@ -250,6 +252,7 @@ class StartCheckoutService int $cartId, ): Purchase { $cart = $this->resolveCart($tenant, $userId, $cartId); + $this->variantReplacements->replaceHistoricalVariants($cart); $cartItems = $cart->items()->lockForUpdate()->get(); if ($cartItems->isEmpty()) { diff --git a/lang/en/api.php b/lang/en/api.php index 7ecb1f1..304f302 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -34,7 +34,10 @@ return [ 'max_quantity' => 'You can add a maximum of :max.', 'bundle_variant_forbidden' => 'A bundle cannot have a variant.', 'empty_bundle' => 'The bundle has no components.', + 'bundle_component_unavailable' => 'The bundle contains a variant that is no longer available for sale.', 'variant_required' => 'You must select a variant for this item.', + 'variant_unavailable' => 'The selected variant was replaced or is no longer available for sale.', + 'cart_variant_unavailable' => 'The cart contains a replaced variant or one that is no longer available for sale.', 'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.', ], 'purchase' => [ diff --git a/lang/es/api.php b/lang/es/api.php index 55be341..d4fd6cc 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -34,7 +34,10 @@ return [ 'max_quantity' => 'El máximo que se puede agregar es :max.', 'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'empty_bundle' => 'El bundle no tiene componentes.', + 'bundle_component_unavailable' => 'El bundle contiene una variante que ya no está disponible para la venta.', 'variant_required' => 'Debe seleccionar una variante para este ítem.', + 'variant_unavailable' => 'La variante seleccionada fue reemplazada o ya no está disponible para la venta.', + 'cart_variant_unavailable' => 'El carrito contiene una variante reemplazada o que ya no está disponible para la venta.', 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', ], 'purchase' => [