refactor(stock): make reservation expiration authoritative

This commit is contained in:
2026-08-25 15:28:17 -03:00
parent 00ec37d8a0
commit 1881cc1d4b
6 changed files with 52 additions and 64 deletions

View File

@@ -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 public function expireOverdue(): array
{ {
@@ -43,24 +43,24 @@ class ExpireStockReservationsService
->limit(self::BATCH_SIZE) ->limit(self::BATCH_SIZE)
->pluck('id'); ->pluck('id');
Log::channel('commands')->info('Stock reservation cleanup completed.', [ foreach ($reservationIds as $reservationId) {
'command' => 'reservations:expire', $lastReservationId = (int) $reservationId;
'expired_purchases' => $expiredPurchases,
'expired_cart_reservations' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
return [ try {
'purchases' => $expiredPurchases, $owner = $this->expireReservation($lastReservationId);
'cart_reservations' => $expiredCartItems, if ($owner !== null) {
]; $summary[$owner]++;
} catch (Throwable $exception) { }
Log::channel('commands')->error('Stock reservation cleanup failed.', [ } catch (Throwable $exception) {
'command' => 'reservations:expire', $summary['failed']++;
'expired_purchases' => $expiredPurchases, Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
'expired_cart_reservations' => $expiredCartItems, 'command' => 'reservations:expire',
'exception' => $exception, 'stock_reservation_id' => $lastReservationId,
]); 'exception' => $exception,
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
Log::channel('commands')->info('Stock reservation cleanup completed.', [ Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire', 'command' => 'reservations:expire',
@@ -76,20 +76,6 @@ class ExpireStockReservationsService
return $summary; 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 */ /** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
private function expireReservation(int $reservationId): ?string private function expireReservation(int $reservationId): ?string
{ {
@@ -141,7 +127,7 @@ class ExpireStockReservationsService
$cart = Cart::query() $cart = Cart::query()
->whereKey($cartId) ->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId) ->where('current_stock_reservation_id', $reservationId)
->where('status', Cart::STATUS_ACTIVE) ->where('status', 'active')
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($cart === null) { if ($cart === null) {
@@ -155,7 +141,6 @@ class ExpireStockReservationsService
} }
$this->reservations->expire($reservation); $this->reservations->expire($reservation);
$cart->update(['status' => Cart::STATUS_EXPIRED]);
return 'cart_reservations'; return 'cart_reservations';
}); });

View File

@@ -158,9 +158,12 @@ class StockReservationService
}); });
} }
public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation public function attachToPurchase(
{ Cart $cart,
return DB::transaction(function () use ($cart, $purchase): StockReservation { Purchase $purchase,
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
/** @var Cart $lockedCart */ /** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */ /** @var Purchase $lockedPurchase */
@@ -185,7 +188,7 @@ class StockReservationService
} }
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); $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(); $purchase->stock_reservation_id = $reservation->getKey();
$cart->current_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) { DB::transaction(function () use ($purchase, $expiresAt): void {
return; /** @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() /** @var StockReservation $reservation */
->whereKey($purchase->stock_reservation_id) $reservation = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE) ->lockForUpdate()
->update(['expires_at' => $purchase->expires_at]); ->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]);
});
} }
/** /**

View File

@@ -394,7 +394,6 @@ class InvitationPurchaseProvisioner
if ($reservationId === null) { if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([ $reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed', 'status' => 'committed',
'expires_at' => null,
'committed_at' => $now, 'committed_at' => $now,
'released_at' => null, 'released_at' => null,
'expired_at' => null, 'expired_at' => null,

View File

@@ -72,18 +72,6 @@ class ReleaseCheckoutService
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); $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 ( if (
$targetStatus === Purchase::STATUS_EXPIRED $targetStatus === Purchase::STATUS_EXPIRED
&& (! in_array($purchase->status, [ && (! in_array($purchase->status, [
@@ -102,9 +90,11 @@ class ReleaseCheckoutService
}); });
} }
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void private function releasePurchaseReservations(
{ Purchase $purchase,
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); string $targetStatus,
?Cart $cart,
): void {
try { try {
$this->reservations->releaseForPurchase( $this->reservations->releaseForPurchase(
$purchase, $purchase,

View File

@@ -206,7 +206,7 @@ class StartCheckoutService
$cart->getKey(), $cart->getKey(),
); );
$cart->update(['current_purchase_id' => $purchase->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(); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems); $this->loadCartItems($cartItems);
@@ -272,7 +272,7 @@ class StartCheckoutService
$cart->getKey(), $cart->getKey(),
); );
$cart->update(['current_purchase_id' => $purchase->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)); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);

View File

@@ -16,6 +16,8 @@ Artisan::command('reservations:expire', function (): void {
$this->info("Expired purchases: {$expired['purchases']}"); $this->info("Expired purchases: {$expired['purchases']}");
$this->info("Expired cart reservations: {$expired['cart_reservations']}"); $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'); })->purpose('Release expired stock reservations from purchases and abandoned carts');
Schedule::command('reservations:expire') Schedule::command('reservations:expire')