diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 749b500..2c9f5d2 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -336,7 +336,6 @@ class Cart extends Model ], true)) { $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); } diff --git a/app/Domains/Cart/Services/ExpireCartReservationsService.php b/app/Domains/Cart/Services/ExpireCartReservationsService.php deleted file mode 100644 index fa23faf..0000000 --- a/app/Domains/Cart/Services/ExpireCartReservationsService.php +++ /dev/null @@ -1,86 +0,0 @@ -where('status', StockReservation::STATUS_ACTIVE) - ->whereNotNull('expires_at') - ->where('expires_at', '<=', now()) - ->where('id', '>', $lastReservationId) - ->whereHas('currentCart', fn ($query) => $query->where('status', 'active')) - ->whereDoesntHave('purchase', fn ($query) => $query->whereIn('status', [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - Purchase::STATUS_IN_REVIEW, - ])) - ->orderBy('id') - ->limit(500) - ->pluck('id'); - - foreach ($reservationIds as $reservationId) { - $lastReservationId = (int) $reservationId; - - if ($this->expireReservation($lastReservationId)) { - $expired++; - } - } - } while ($reservationIds->count() === 500); - - return $expired; - } - - private function expireReservation(int $reservationId): bool - { - $cartId = Cart::query() - ->where('current_stock_reservation_id', $reservationId) - ->where('status', 'active') - ->value('id'); - if ($cartId === null) { - return false; - } - - return DB::transaction(function () use ($cartId, $reservationId): bool { - /** @var Cart|null $cart */ - $cart = Cart::query() - ->whereKey($cartId) - ->where('current_stock_reservation_id', $reservationId) - ->where('status', 'active') - ->lockForUpdate() - ->first(); - if ($cart === null) { - return false; - } - - /** @var StockReservation|null $reservation */ - $reservation = StockReservation::query()->lockForUpdate()->find($reservationId); - if ($reservation === null - || $reservation->status !== StockReservation::STATUS_ACTIVE - || $reservation->expires_at === null - || $reservation->expires_at->isFuture()) { - return false; - } - - $this->reservations->expire($reservation); - - return true; - }); - } -} diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php index 39f7bde..4acf01c 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -2,50 +2,169 @@ namespace App\Domains\Catalog\Services; -use App\Domains\Cart\Services\ExpireCartReservationsService; -use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; class ExpireStockReservationsService { + private const BATCH_SIZE = 500; + public function __construct( - private readonly CheckoutService $checkout, - private readonly ExpireCartReservationsService $carts, + private readonly ReleaseCheckoutService $purchases, + private readonly StockReservationService $reservations, ) {} /** - * @return array{purchases: int, cart_reservations: int} + * @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int} */ public function expireOverdue(): array { - $expiredPurchases = null; - $expiredCartItems = null; + $summary = [ + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 0, + 'failed' => 0, + ]; + $lastReservationId = 0; - try { - $expiredPurchases = $this->checkout->expireOverduePurchases(); - $expiredCartItems = $this->carts->expireOverdue(); + do { + $reservationIds = StockReservation::query() + ->where('status', StockReservation::STATUS_ACTIVE) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->where('id', '>', $lastReservationId) + ->orderBy('id') + ->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); - throw $exception; + Log::channel('commands')->info('Stock reservation cleanup completed.', [ + 'command' => 'reservations:expire', + 'expired_purchases' => $summary['purchases'], + 'expired_cart_reservations' => $summary['cart_reservations'], + 'expired_orphan_reservations' => $summary['orphan_reservations'], + 'failed_reservations' => $summary['failed'], + 'total_expired' => $summary['purchases'] + + $summary['cart_reservations'] + + $summary['orphan_reservations'], + ]); + + return $summary; + } + + /** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */ + private function expireReservation(int $reservationId): ?string + { + $purchaseId = Purchase::query() + ->where('stock_reservation_id', $reservationId) + ->value('id'); + if ($purchaseId !== null) { + return $this->expirePurchase((int) $purchaseId); } + + $cartId = Cart::query() + ->where('current_stock_reservation_id', $reservationId) + ->where('status', 'active') + ->value('id'); + if ($cartId !== null) { + return $this->expireCart((int) $cartId, $reservationId); + } + + return $this->expireOrphan($reservationId); + } + + /** @return 'purchases'|null */ + private function expirePurchase(int $purchaseId): ?string + { + /** @var Purchase|null $purchase */ + $purchase = Purchase::query()->find($purchaseId); + if ($purchase === null) { + return null; + } + + $purchase = $this->purchases->expire($purchase); + if ($purchase->status === Purchase::STATUS_EXPIRED) { + return 'purchases'; + } + + $reservation = $purchase->stockReservation; + if ($this->isOverdue($reservation)) { + throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.'); + } + + return null; + } + + /** @return 'cart_reservations'|null */ + private function expireCart(int $cartId, int $reservationId): ?string + { + return DB::transaction(function () use ($cartId, $reservationId): ?string { + /** @var Cart|null $cart */ + $cart = Cart::query() + ->whereKey($cartId) + ->where('current_stock_reservation_id', $reservationId) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + if ($cart === null) { + return null; + } + + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($reservationId); + if (! $this->isOverdue($reservation)) { + return null; + } + + $this->reservations->expire($reservation); + + return 'cart_reservations'; + }); + } + + /** @return 'orphan_reservations'|null */ + private function expireOrphan(int $reservationId): ?string + { + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->find($reservationId); + if (! $this->isOverdue($reservation)) { + return null; + } + + $this->reservations->expire($reservation); + + return 'orphan_reservations'; + } + + private function isOverdue(?StockReservation $reservation): bool + { + return $reservation !== null + && $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture(); } } 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 5e9a3ac..19341b8 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner if ($purchaseId !== null) { DB::table('compras')->where('id', $purchaseId)->update([ 'status' => 'paid', - 'expires_at' => null, 'total' => 0, 'updated_at' => $now, ]); @@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner 'cart_id' => null, 'status' => 'paid', 'payment_method' => self::PAYMENT_METHOD, - 'expires_at' => null, 'total' => 0, 'dni' => null, 'transfer_payer_dni' => null, @@ -396,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/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 5db012f..118baa5 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -36,6 +36,7 @@ class PurchaseController extends Controller return PurchaseResource::collection( Purchase::query() + ->with('stockReservation') ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses)) @@ -101,7 +102,13 @@ class PurchaseController extends Controller ? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')) : null; - $updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool { + $updated = DB::transaction(function () use ( + $checkoutService, + $compra, + $method, + $purchaseState, + $transferPayerDni, + ): bool { /** @var Purchase|null $purchase */ $purchase = Purchase::query() ->whereKey($compra->getKey()) @@ -128,9 +135,6 @@ class PurchaseController extends Controller $purchaseUpdate = [ 'payment_method' => $method, 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes( - max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) - ), 'total' => $purchase->calculateCurrentTotalAmount(), ]; @@ -138,6 +142,12 @@ class PurchaseController extends Controller $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; } $purchase->update($purchaseUpdate); + $checkoutService->refreshReservationExpiration( + $purchase, + now()->addMinutes( + max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)), + ), + ); return true; }); @@ -150,7 +160,6 @@ class PurchaseController extends Controller $compra->refresh(); $totalAmount = (float) $compra->total; - $checkoutService->syncReservationExpiration($compra); if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 0327258..d3e2bc8 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -24,7 +24,6 @@ use Illuminate\Support\Facades\DB; 'user_id', 'status', 'payment_method', - 'expires_at', 'total', 'dni', 'transfer_payer_dni', @@ -80,7 +79,6 @@ class Purchase extends Model 'cart_id' => 'integer', 'stock_reservation_id' => 'integer', 'user_id' => 'integer', - 'expires_at' => 'datetime', 'total' => 'decimal:2', ]; } diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 1c69313..81d0ca3 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -48,7 +48,7 @@ class PurchaseResource extends JsonResource 'created_at' => $this->created_at, 'status' => $this->status, 'payment_method' => $this->payment_method, - 'expires_at' => $this->expires_at, + 'expires_at' => $this->stockReservation?->expires_at, 'dni' => $this->dni, 'transfer_payer_dni' => $this->transfer_payer_dni, 'telefono' => $this->telefono, diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index 231fe0d..d827020 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -61,10 +61,7 @@ class CompleteCheckoutService $this->purchaseState->lockCurrentCart($purchase); - if ( - $purchase->status !== Purchase::STATUS_PENDING_PAYMENT - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { + if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_available_for_review'), ]); @@ -72,9 +69,8 @@ class CompleteCheckoutService $purchase->update([ 'status' => Purchase::STATUS_IN_REVIEW, - 'expires_at' => null, ]); - $this->reservations->syncPurchaseExpiration($purchase); + $this->reservations->refreshForPurchase($purchase, null); return $this->loadPurchase($purchase); }); @@ -195,7 +191,7 @@ class CompleteCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load(['items.imageAttachment', 'stockReservation']); } private function itemKey(int $catalogItemId, ?int $variantId): string diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php index 223a8ba..0976c2e 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -8,6 +8,6 @@ class PurchaseResponseLoader { public function load(Purchase $purchase): Purchase { - return $purchase->load(['tenant', 'items.imageAttachment']); + return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']); } } diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index cb1263e..88bc7e5 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -7,9 +7,7 @@ use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; -use Throwable; class ReleaseCheckoutService { @@ -32,40 +30,6 @@ class ReleaseCheckoutService return $this->release($purchase, Purchase::STATUS_EXPIRED); } - public function expireOverdue(): int - { - $expiredCount = 0; - - Purchase::query() - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) - ->whereNotNull('expires_at') - ->where('expires_at', '<=', now()) - ->orderBy('id') - ->eachById(function (Purchase $purchase) use (&$expiredCount): void { - try { - $purchase = $this->expire($purchase); - } catch (Throwable $exception) { - Log::channel('commands')->error('Failed to expire overdue purchase.', [ - 'command' => 'reservations:expire', - 'purchase_id' => $purchase->getKey(), - 'tenant_codigo' => $purchase->tenant_codigo, - 'cart_id' => $purchase->cart_id, - 'status' => $purchase->status, - 'expires_at' => $purchase->expires_at, - 'exception' => $exception, - ]); - - return; - } - - if ($purchase->status === Purchase::STATUS_EXPIRED) { - $expiredCount++; - } - }); - - return $expiredCount; - } - private function release( Purchase $purchase, string $targetStatus, @@ -100,14 +64,19 @@ class ReleaseCheckoutService return $this->loadPurchase($purchase); } + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ( $targetStatus === Purchase::STATUS_EXPIRED - && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) + && (! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) || ! $this->hasOverdueActiveReservation($purchase)) ) { return $this->loadPurchase($purchase); } - $this->releasePurchaseReservations($purchase, $targetStatus); + $this->releasePurchaseReservations($purchase, $targetStatus, $cart); $purchase->update(['status' => $targetStatus]); @@ -115,9 +84,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, @@ -211,6 +182,17 @@ class ReleaseCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load(['items.imageAttachment', 'stockReservation']); + } + + private function hasOverdueActiveReservation(Purchase $purchase): bool + { + /** @var StockReservation|null $reservation */ + $reservation = $purchase->stockReservation()->lockForUpdate()->first(); + + return $reservation !== null + && $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture(); } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 7c719f4..d3cb9b2 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -12,6 +12,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -204,7 +205,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); @@ -270,7 +271,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); @@ -315,7 +316,6 @@ class StartCheckoutService ], true)) { $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); $this->reservations->releaseForPurchase( $currentPurchase, @@ -410,13 +410,17 @@ class StartCheckoutService 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, 'payment_method' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), 'total' => $total, ]); } + private function checkoutExpiration(): Carbon + { + return now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ); + } + /** @param Collection $cartItems */ private function loadCartItems(Collection $cartItems): void { diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 1a40c86..c7e7bea 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; /** @@ -78,13 +79,8 @@ class CheckoutService return $this->releaser->expire($purchase); } - public function expireOverduePurchases(): int + public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void { - return $this->releaser->expireOverdue(); - } - - public function syncReservationExpiration(Purchase $purchase): void - { - $this->reservations->syncPurchaseExpiration($purchase); + $this->reservations->refreshForPurchase($purchase, $expiresAt); } } diff --git a/app/Domains/Purchase/Services/PurchaseStateGuard.php b/app/Domains/Purchase/Services/PurchaseStateGuard.php index 2c9d4f9..9e43305 100644 --- a/app/Domains/Purchase/Services/PurchaseStateGuard.php +++ b/app/Domains/Purchase/Services/PurchaseStateGuard.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Services; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Models\Purchase; use Illuminate\Validation\ValidationException; @@ -11,15 +12,32 @@ class PurchaseStateGuard { public function assertNotExpired(Purchase $purchase): void { - $hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED; - $hasExpiredByTime = in_array($purchase->status, [ + if ($purchase->status === Purchase::STATUS_EXPIRED) { + throw new PurchaseExpiredException; + } + + if (! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, - ], true) - && $purchase->expires_at !== null - && $purchase->expires_at->isPast(); + ], true)) { + return; + } - if ($hasExpiredStatus || $hasExpiredByTime) { + /** @var StockReservation|null $reservation */ + $reservation = $purchase->relationLoaded('stockReservation') + ? $purchase->getRelation('stockReservation') + : ($purchase->exists + ? $purchase->stockReservation()->first() + : null); + + if ($reservation !== null && ( + $reservation->status === StockReservation::STATUS_EXPIRED + || ( + $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture() + ) + )) { throw new PurchaseExpiredException; } } diff --git a/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php b/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php new file mode 100644 index 0000000..4bdd362 --- /dev/null +++ b/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php @@ -0,0 +1,51 @@ +whereNotNull('stock_reservation_id') + ->orderBy('id') + ->chunkById(500, function ($purchases): void { + foreach ($purchases as $purchase) { + DB::table('stock_reservations') + ->where('id', $purchase->stock_reservation_id) + ->where('status', 'active') + ->update(['expires_at' => $purchase->expires_at]); + } + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->dropIndex(['expires_at']); + $table->dropColumn('expires_at'); + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->timestamp('expires_at')->nullable()->index()->after('payment_method'); + }); + + DB::table('compras') + ->whereNotNull('stock_reservation_id') + ->orderBy('id') + ->chunkById(500, function ($purchases): void { + foreach ($purchases as $purchase) { + DB::table('compras') + ->where('id', $purchase->id) + ->update([ + 'expires_at' => DB::table('stock_reservations') + ->where('id', $purchase->stock_reservation_id) + ->value('expires_at'), + ]); + } + }); + } +}; 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')