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
{
@@ -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';
});

View File

@@ -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]);
});
}
/**

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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')