From f6f138e180f82d883dac6cd4d8a9bd9ba15add49 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 15:58:41 -0300 Subject: [PATCH] refactor(stock): make expired reservations terminal --- .../Cart/Controllers/CartController.php | 10 +++ app/Domains/Cart/Services/CartService.php | 41 ++++++++- app/Domains/Cart/routes/api.php | 1 + .../ExpireStockReservationsService.php | 14 +++ .../Services/StockReservationService.php | 87 ++++++++++++++++--- .../Checkout/ReleaseCheckoutService.php | 28 ++++-- .../Checkout/StartCheckoutService.php | 8 -- lang/en/api.php | 4 +- lang/es/api.php | 4 +- 9 files changed, 164 insertions(+), 33 deletions(-) diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 368a77c..bb08b9e 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -86,4 +86,14 @@ class CartController extends Controller 'message' => __('api.cart.item_removed'), ]); } + + public function restart(Request $request, Tenant $tenant): CartResource + { + return CartResource::make( + $this->cartService->restartExpired($tenant, $request), + )->additional([ + 'code' => 'cart.restarted', + 'message' => __('api.cart.restarted'), + ]); + } } diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 9c5f873..fc0e573 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,7 +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; use Illuminate\Http\Request; @@ -97,6 +97,45 @@ class CartService return $this->loadCart($cart, $tenant); } + public function restartExpired(Tenant $tenant, Request $request): Cart + { + $identity = $this->requireIdentity($request); + $cart = $this->findCartOrFail($tenant, $identity); + if ($cart->current_stock_reservation_id === null + || ! app(ExpireStockReservationsService::class) + ->expireIfOverdue($cart->current_stock_reservation_id)) { + throw ValidationException::withMessages([ + 'cart' => __('api.cart.reservation_not_expired'), + ]); + } + + $newCart = DB::transaction(function () use ($cart, $identity, $tenant): Cart { + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + /** @var StockReservation|null $reservation */ + $reservation = $cart->current_stock_reservation_id === null + ? null + : StockReservation::query() + ->lockForUpdate() + ->find($cart->current_stock_reservation_id); + + if ($reservation?->status !== StockReservation::STATUS_EXPIRED) { + throw ValidationException::withMessages([ + 'cart' => __('api.cart.reservation_not_expired'), + ]); + } + + $cart->update([ + 'status' => 'abandoned', + 'current_purchase_id' => null, + ]); + + return $this->findOrCreateCart($tenant, $identity); + }); + + return $this->loadCart($newCart, $tenant); + } + public function makeGuestTokenCookie(string $guestToken): Cookie { $secure = (bool) config('session.secure'); diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 28488a7..9c5da2f 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; Route::prefix('tenants/{tenant:codigo}') ->group(function (): void { Route::get('cart', [CartController::class, 'show']); + Route::post('cart/restart', [CartController::class, 'restart']); Route::post('cart/items', [CartController::class, 'addItem']); Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']); Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']); diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index 4acf01c..33e87ff 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -76,6 +76,20 @@ class ExpireStockReservationsService return $summary; } + public function expireIfOverdue(int $reservationId): bool + { + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->find($reservationId); + if ($reservation?->status === StockReservation::STATUS_EXPIRED) { + return true; + } + if (! $this->isOverdue($reservation)) { + return false; + } + + return $this->expireReservation($reservationId) !== null; + } + /** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */ private function expireReservation(int $reservationId): ?string { diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 74d5f04..c52f4ea 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Services; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; @@ -43,13 +44,8 @@ class StockReservationService ? null : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); - if ($reservation !== null - && $reservation->status === StockReservation::STATUS_ACTIVE - && $reservation->expires_at !== null - && $reservation->expires_at->isPast()) { - $this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null); - $lockedCart->update(['current_stock_reservation_id' => null]); - $reservation = null; + if ($reservation !== null) { + $this->assertUsableCartReservation($reservation); } if ($requirements === []) { @@ -67,7 +63,7 @@ class StockReservationService return null; } - if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) { + if ($reservation === null) { $reservation = StockReservation::query()->create([ 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => $this->expiration(), @@ -175,9 +171,7 @@ class StockReservationService /** @var StockReservation $reservation */ $reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id); - if ($reservation->status !== StockReservation::STATUS_ACTIVE) { - throw new \InvalidArgumentException('La reserva de stock no está activa.'); - } + $this->assertUsableCartReservation($reservation); $linkedPurchase = Purchase::query() ->where('stock_reservation_id', $reservation->getKey()) @@ -213,6 +207,9 @@ class StockReservationService if ($reservation->status !== StockReservation::STATUS_ACTIVE) { throw new \InvalidArgumentException('La reserva de stock no está activa.'); } + if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { + throw new StockReservationExpiredException; + } $lines = $this->lockLines($reservation); if ($lines->isEmpty()) { @@ -267,6 +264,50 @@ class StockReservationService }); } + public function returnToCart(Purchase $purchase, Cart $cart): StockReservation + { + return DB::transaction(function () use ($purchase, $cart): StockReservation { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + + if ($purchase->stock_reservation_id === null + || $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) { + throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($purchase->stock_reservation_id); + $this->assertUsableCartReservation($reservation); + + $purchase->update(['stock_reservation_id' => null]); + $cart->update(['current_purchase_id' => null]); + $reservation->update(['expires_at' => $this->expiration()]); + + return $reservation->fresh('lines'); + }); + } + + public function assertCartReservationUsable(Cart $cart): void + { + DB::transaction(function () use ($cart): void { + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + if ($cart->current_stock_reservation_id === null) { + return; + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($cart->current_stock_reservation_id); + $this->assertUsableCartReservation($reservation); + }); + } + public function releaseCurrentCartReservation( Cart $cart, string $reason = self::REASON_CART_CHANGED, @@ -318,6 +359,9 @@ class StockReservationService if ($reservation->status !== StockReservation::STATUS_ACTIVE) { throw new \InvalidArgumentException('La reserva de stock no está activa.'); } + if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { + throw new StockReservationExpiredException; + } $reservation->update(['expires_at' => $expiresAt]); }); @@ -420,9 +464,24 @@ class StockReservationService 'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null, ]); - Cart::query() - ->where('current_stock_reservation_id', $reservation->getKey()) - ->update(['current_stock_reservation_id' => null]); + if ($status === StockReservation::STATUS_RELEASED) { + Cart::query() + ->where('current_stock_reservation_id', $reservation->getKey()) + ->update(['current_stock_reservation_id' => null]); + } + } + + private function assertUsableCartReservation(StockReservation $reservation): void + { + if ($reservation->status === StockReservation::STATUS_EXPIRED + || ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) { + throw new StockReservationExpiredException; + } + + if ($reservation->status !== StockReservation::STATUS_ACTIVE + || $reservation->expires_at === null) { + throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.'); + } } private function expiration(): Carbon diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index f347558..9879f91 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -72,6 +72,18 @@ class ReleaseCheckoutService $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($targetStatus === Purchase::STATUS_CANCELLED + && $cart?->status === 'active' + && in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { + $this->reservations->returnToCart($purchase, $cart); + $purchase->update(['status' => Purchase::STATUS_CANCELLED]); + + return $this->loadPurchase($purchase); + } + if ( $targetStatus === Purchase::STATUS_EXPIRED && (! in_array($purchase->status, [ @@ -118,17 +130,17 @@ class ReleaseCheckoutService } if ($cart->status === 'active') { + $cartUpdate = [ + 'current_purchase_id' => null, + ]; + if ($targetStatus !== Purchase::STATUS_EXPIRED) { + $cartUpdate['current_stock_reservation_id'] = null; + } + Cart::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $purchase->getKey()) - ->update([ - 'current_purchase_id' => null, - 'current_stock_reservation_id' => null, - ]); - - if ($targetStatus === Purchase::STATUS_CANCELLED) { - $this->reservations->syncCart($cart); - } + ->update($cartUpdate); return; } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 2ee6d92..b34fd41 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -319,14 +319,6 @@ class StartCheckoutService $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, ]); - $this->reservations->releaseForPurchase( - $currentPurchase, - reason: StockReservationService::REASON_PURCHASE_SUPERSEDED, - ); - $cart->update([ - 'current_purchase_id' => null, - 'current_stock_reservation_id' => null, - ]); } return $cart; diff --git a/lang/en/api.php b/lang/en/api.php index ddb6021..e8dc73e 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -34,7 +34,9 @@ return [ 'bundle_variant_forbidden' => 'A bundle cannot have a variant.', 'empty_bundle' => 'The bundle has no components.', 'variant_required' => 'You must select a variant for this item.', - 'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.', + 'reservation_expired' => 'The stock reservation has expired. Abandon this cart to start a new one.', + 'reservation_not_expired' => 'The cart can only be restarted after its stock reservation expires.', + 'restarted' => 'The expired cart was abandoned. You can start a new one.', ], 'purchase' => [ 'expired' => 'The purchase has expired. Please start a new purchase.', diff --git a/lang/es/api.php b/lang/es/api.php index 7c8cdf3..21e90ea 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -34,7 +34,9 @@ return [ 'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'empty_bundle' => 'El bundle no tiene componentes.', 'variant_required' => 'Debe seleccionar una variante para este ítem.', - 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', + 'reservation_expired' => 'La reserva de stock venció. Abandoná este carrito para comenzar uno nuevo.', + 'reservation_not_expired' => 'El carrito sólo puede reiniciarse cuando su reserva de stock está vencida.', + 'restarted' => 'Carrito vencido abandonado. Podés comenzar uno nuevo.', ], 'purchase' => [ 'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",