refactor(stock): make reservation expiration authoritative
This commit is contained in:
@@ -336,7 +336,6 @@ class Cart extends Model
|
||||
], true)) {
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpireCartReservationsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
public function expireOverdue(): int
|
||||
{
|
||||
$expired = 0;
|
||||
$lastReservationId = 0;
|
||||
|
||||
do {
|
||||
$reservationIds = StockReservation::query()
|
||||
->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, CartItem> $cartItems */
|
||||
private function loadCartItems(Collection $cartItems): void
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('compras')
|
||||
->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'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user