From d69a6210f68871dc1a2258a93fc5d5262cab0563 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 16:09:52 -0300 Subject: [PATCH] refactor(stock): propagate reservation expiration --- app/Domains/Cart/Models/Cart.php | 10 +++++++ app/Domains/Cart/Services/CartService.php | 20 ++++++++++--- .../ExpireStockReservationsService.php | 3 +- .../Checkout/ReleaseCheckoutService.php | 24 +++++++++++---- .../Checkout/StartCheckoutService.php | 7 ++++- ...0_propagate_expired_stock_reservations.php | 30 +++++++++++++++++++ 6 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 21a0945..9c6ea42 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -38,6 +38,16 @@ class Cart extends Model protected $table = 'carritos'; + public const STATUS_ACTIVE = 'active'; + + public const STATUS_CHECKOUT = 'checkout'; + + public const STATUS_CONVERTED = 'converted'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_ABANDONED = 'abandoned'; + public const ORIGIN_USER = 'user'; public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout'; diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 842c490..2d7b0d2 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,6 +4,7 @@ namespace App\Domains\Cart\Services; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Tenant\Models\Tenant; @@ -126,7 +127,7 @@ class CartService } $cart->update([ - 'status' => 'abandoned', + 'status' => Cart::STATUS_ABANDONED, 'current_purchase_id' => null, ]); @@ -161,7 +162,7 @@ class CartService { $cart = new Cart([ 'tenant_codigo' => $tenant->codigo, - 'status' => 'active', + 'status' => Cart::STATUS_ACTIVE, ]); $cart->setRelation('items', collect()); @@ -262,12 +263,14 @@ class CartService { return Cart::query() ->where('tenant_codigo', $tenant->codigo) - ->where('status', 'active') + ->where('origin', Cart::ORIGIN_USER) + ->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED]) ->when( $identity['user_id'] !== null, fn ($query) => $query->where('user_id', $identity['user_id']), fn ($query) => $query->where('guest_token', $identity['guest_token']), ) + ->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE]) ->first(); } @@ -290,9 +293,18 @@ class CartService */ protected function findOrCreateCart(Tenant $tenant, array $identity): Cart { + $cart = $this->findCart($tenant, $identity); + if ($cart?->status === Cart::STATUS_EXPIRED) { + throw new StockReservationExpiredException; + } + if ($cart !== null) { + return $cart; + } + $attributes = [ 'tenant_codigo' => $tenant->codigo, - 'status' => 'active', + 'status' => Cart::STATUS_ACTIVE, + 'origin' => Cart::ORIGIN_USER, ]; if ($identity['user_id'] !== null) { diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index 33e87ff..58bf5a8 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -141,7 +141,7 @@ class ExpireStockReservationsService $cart = Cart::query() ->whereKey($cartId) ->where('current_stock_reservation_id', $reservationId) - ->where('status', 'active') + ->where('status', Cart::STATUS_ACTIVE) ->lockForUpdate() ->first(); if ($cart === null) { @@ -155,6 +155,7 @@ class ExpireStockReservationsService } $this->reservations->expire($reservation); + $cart->update(['status' => Cart::STATUS_EXPIRED]); return 'cart_reservations'; }); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 1d7c503..a63ef14 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -129,13 +129,25 @@ class ReleaseCheckoutService return; } - if ($cart->status === 'active') { + if ($targetStatus === Purchase::STATUS_EXPIRED + && in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) { + Cart::query() + ->whereKey($cart->getKey()) + ->where('current_purchase_id', $purchase->getKey()) + ->where('current_stock_reservation_id', $purchase->stock_reservation_id) + ->update([ + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, + ]); + + return; + } + + if ($cart->status === Cart::STATUS_ACTIVE) { $cartUpdate = [ 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, ]; - if ($targetStatus !== Purchase::STATUS_EXPIRED) { - $cartUpdate['current_stock_reservation_id'] = null; - } Cart::query() ->whereKey($cart->getKey()) @@ -145,12 +157,12 @@ class ReleaseCheckoutService return; } - if ($cart->status !== 'checkout') { + if ($cart->status !== Cart::STATUS_CHECKOUT) { return; } if (! $cart->trashed()) { - $cart->update(['status' => 'converted']); + $cart->update(['status' => Cart::STATUS_CONVERTED]); $cart->delete(); } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 988940e..b34fd41 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\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; @@ -329,7 +330,11 @@ class StartCheckoutService throw new NotFoundHttpException('Cart not found for tenant.'); } - if ($cart->status !== 'active') { + if ($cart->status === Cart::STATUS_EXPIRED) { + throw new StockReservationExpiredException; + } + + if ($cart->status !== Cart::STATUS_ACTIVE) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.inactive_cart'), ]); diff --git a/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php b/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php new file mode 100644 index 0000000..bd323b9 --- /dev/null +++ b/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php @@ -0,0 +1,30 @@ + $query + ->select('id') + ->from('stock_reservations') + ->where('status', 'expired'); + + DB::table('compras') + ->whereIn('status', ['created', 'pending_payment']) + ->whereIn('stock_reservation_id', $expiredReservationIds) + ->update(['status' => 'expired']); + + DB::table('carritos') + ->whereIn('status', ['active', 'checkout']) + ->whereIn('current_stock_reservation_id', $expiredReservationIds) + ->update(['status' => 'expired']); + } + + public function down(): void + { + // Terminal business states cannot be reversed without inventing their prior state. + } +};