From 1881cc1d4b5f8a3874c5e7a6bf04f35ccc589085 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 15:28:17 -0300 Subject: [PATCH] refactor(stock): make reservation expiration authoritative --- .../ExpireStockReservationsService.php | 53 +++++++------------ .../Services/StockReservationService.php | 36 ++++++++----- .../InvitationPurchaseProvisioner.php | 1 - .../Checkout/ReleaseCheckoutService.php | 20 ++----- .../Checkout/StartCheckoutService.php | 4 +- routes/console.php | 2 + 6 files changed, 52 insertions(+), 64 deletions(-) diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index dbce34b..4acf01c 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -21,7 +21,7 @@ class ExpireStockReservationsService ) {} /** - * @return array{purchases: int, cart_reservations: int} + * @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int} */ public function expireOverdue(): array { @@ -43,24 +43,24 @@ class ExpireStockReservationsService ->limit(self::BATCH_SIZE) ->pluck('id'); - Log::channel('commands')->info('Stock reservation cleanup completed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => $expiredPurchases, - 'expired_cart_reservations' => $expiredCartItems, - 'total_expired' => $expiredPurchases + $expiredCartItems, - ]); + foreach ($reservationIds as $reservationId) { + $lastReservationId = (int) $reservationId; - return [ - 'purchases' => $expiredPurchases, - 'cart_reservations' => $expiredCartItems, - ]; - } catch (Throwable $exception) { - Log::channel('commands')->error('Stock reservation cleanup failed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => $expiredPurchases, - 'expired_cart_reservations' => $expiredCartItems, - 'exception' => $exception, - ]); + try { + $owner = $this->expireReservation($lastReservationId); + if ($owner !== null) { + $summary[$owner]++; + } + } catch (Throwable $exception) { + $summary['failed']++; + Log::channel('commands')->error('Failed to expire overdue stock reservation.', [ + 'command' => 'reservations:expire', + 'stock_reservation_id' => $lastReservationId, + 'exception' => $exception, + ]); + } + } + } while ($reservationIds->count() === self::BATCH_SIZE); Log::channel('commands')->info('Stock reservation cleanup completed.', [ 'command' => 'reservations:expire', @@ -76,20 +76,6 @@ 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 { @@ -141,7 +127,7 @@ class ExpireStockReservationsService $cart = Cart::query() ->whereKey($cartId) ->where('current_stock_reservation_id', $reservationId) - ->where('status', Cart::STATUS_ACTIVE) + ->where('status', 'active') ->lockForUpdate() ->first(); if ($cart === null) { @@ -155,7 +141,6 @@ class ExpireStockReservationsService } $this->reservations->expire($reservation); - $cart->update(['status' => Cart::STATUS_EXPIRED]); return 'cart_reservations'; }); diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 8215302..74d5f04 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -158,9 +158,12 @@ class StockReservationService }); } - public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation - { - return DB::transaction(function () use ($cart, $purchase): StockReservation { + public function attachToPurchase( + Cart $cart, + Purchase $purchase, + Carbon $expiresAt, + ): StockReservation { + return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation { /** @var Cart $lockedCart */ $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); /** @var Purchase $lockedPurchase */ @@ -185,7 +188,7 @@ class StockReservationService } $lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); - $reservation->update(['expires_at' => $lockedPurchase->expires_at]); + $reservation->update(['expires_at' => $expiresAt]); $purchase->stock_reservation_id = $reservation->getKey(); $cart->current_stock_reservation_id = $reservation->getKey(); @@ -299,16 +302,25 @@ class StockReservationService }); } - public function syncPurchaseExpiration(Purchase $purchase): void + public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void { - if ($purchase->stock_reservation_id === null) { - return; - } + DB::transaction(function () use ($purchase, $expiresAt): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + if ($purchase->stock_reservation_id === null) { + throw new \InvalidArgumentException('La compra no tiene una reserva de stock.'); + } - StockReservation::query() - ->whereKey($purchase->stock_reservation_id) - ->where('status', StockReservation::STATUS_ACTIVE) - ->update(['expires_at' => $purchase->expires_at]); + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($purchase->stock_reservation_id); + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + throw new \InvalidArgumentException('La reserva de stock no está activa.'); + } + + $reservation->update(['expires_at' => $expiresAt]); + }); } /** diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php index 632e69b..19341b8 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -394,7 +394,6 @@ class InvitationPurchaseProvisioner if ($reservationId === null) { $reservationId = DB::table('stock_reservations')->insertGetId([ 'status' => 'committed', - 'expires_at' => null, 'committed_at' => $now, 'released_at' => null, 'expired_at' => null, diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 41274fa..f347558 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -72,18 +72,6 @@ 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, [ @@ -102,9 +90,11 @@ class ReleaseCheckoutService }); } - private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void - { - $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + private function releasePurchaseReservations( + Purchase $purchase, + string $targetStatus, + ?Cart $cart, + ): void { try { $this->reservations->releaseForPurchase( $purchase, diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 6fbf4e7..2ee6d92 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -206,7 +206,7 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); - $this->reservations->attachToPurchase($cart, $purchase); + $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); $this->loadCartItems($cartItems); @@ -272,7 +272,7 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); - $this->reservations->attachToPurchase($cart, $purchase); + $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); return $this->loadPurchase($purchase); diff --git a/routes/console.php b/routes/console.php index cc8bdf4..e9597ab 100644 --- a/routes/console.php +++ b/routes/console.php @@ -16,6 +16,8 @@ Artisan::command('reservations:expire', function (): void { $this->info("Expired purchases: {$expired['purchases']}"); $this->info("Expired cart reservations: {$expired['cart_reservations']}"); + $this->info("Expired orphan reservations: {$expired['orphan_reservations']}"); + $this->info("Failed reservations: {$expired['failed']}"); })->purpose('Release expired stock reservations from purchases and abandoned carts'); Schedule::command('reservations:expire')