refactor(stock): make expired reservations terminal
This commit is contained in:
@@ -86,4 +86,14 @@ class CartController extends Controller
|
||||
'message' => __('api.cart.item_removed'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restart(Request $request, Tenant $tenant): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->restartExpired($tenant, $request),
|
||||
)->additional([
|
||||
'code' => 'cart.restarted',
|
||||
'message' => __('api.cart.restarted'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ class Cart extends Model
|
||||
|
||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||
$cartQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
@@ -183,6 +184,7 @@ class Cart extends Model
|
||||
$excludedPurchaseId,
|
||||
): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -284,6 +286,7 @@ class Cart extends Model
|
||||
{
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -334,9 +337,14 @@ class Cart extends Model
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
]);
|
||||
|
||||
$this->current_purchase_id = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app(StockReservationService::class)->releaseForPurchase(
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
@@ -94,6 +97,45 @@ class CartService
|
||||
return $this->loadCart($cart, $tenant);
|
||||
}
|
||||
|
||||
public function restartExpired(Tenant $tenant, Request $request): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
if ($cart->current_stock_reservation_id === null
|
||||
|| ! app(ExpireStockReservationsService::class)
|
||||
->expireIfOverdue($cart->current_stock_reservation_id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart' => __('api.cart.reservation_not_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
$newCart = DB::transaction(function () use ($cart, $identity, $tenant): Cart {
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $cart->current_stock_reservation_id === null
|
||||
? null
|
||||
: StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->find($cart->current_stock_reservation_id);
|
||||
|
||||
if ($reservation?->status !== StockReservation::STATUS_EXPIRED) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart' => __('api.cart.reservation_not_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
$cart->update([
|
||||
'status' => 'abandoned',
|
||||
'current_purchase_id' => null,
|
||||
]);
|
||||
|
||||
return $this->findOrCreateCart($tenant, $identity);
|
||||
});
|
||||
|
||||
return $this->loadCart($newCart, $tenant);
|
||||
}
|
||||
|
||||
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||
{
|
||||
$secure = (bool) config('session.secure');
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('tenants/{tenant:codigo}')
|
||||
->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/restart', [CartController::class, 'restart']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class StockReservationExpiredException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('api.cart.reservation_expired'));
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,20 @@ 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
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
@@ -43,13 +44,8 @@ class StockReservationService
|
||||
? null
|
||||
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||
|
||||
if ($reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& $reservation->expires_at->isPast()) {
|
||||
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||
$reservation = null;
|
||||
if ($reservation !== null) {
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
}
|
||||
|
||||
if ($requirements === []) {
|
||||
@@ -67,7 +63,7 @@ class StockReservationService
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
if ($reservation === null) {
|
||||
$reservation = StockReservation::query()->create([
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
@@ -175,9 +171,7 @@ class StockReservationService
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$linkedPurchase = Purchase::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
@@ -213,6 +207,9 @@ class StockReservationService
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$lines = $this->lockLines($reservation);
|
||||
if ($lines->isEmpty()) {
|
||||
@@ -267,6 +264,50 @@ class StockReservationService
|
||||
});
|
||||
}
|
||||
|
||||
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||
{
|
||||
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
|
||||
if ($purchase->stock_reservation_id === null
|
||||
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$purchase->update(['stock_reservation_id' => null]);
|
||||
$cart->update(['current_purchase_id' => null]);
|
||||
$reservation->update(['expires_at' => $this->expiration()]);
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function assertCartReservationUsable(Cart $cart): void
|
||||
{
|
||||
DB::transaction(function () use ($cart): void {
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
if ($cart->current_stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($cart->current_stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseCurrentCartReservation(
|
||||
Cart $cart,
|
||||
string $reason = self::REASON_CART_CHANGED,
|
||||
@@ -318,6 +359,9 @@ class StockReservationService
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
});
|
||||
@@ -420,9 +464,24 @@ class StockReservationService
|
||||
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||
]);
|
||||
|
||||
Cart::query()
|
||||
->where('current_stock_reservation_id', $reservation->getKey())
|
||||
->update(['current_stock_reservation_id' => null]);
|
||||
if ($status === StockReservation::STATUS_RELEASED) {
|
||||
Cart::query()
|
||||
->where('current_stock_reservation_id', $reservation->getKey())
|
||||
->update(['current_stock_reservation_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||
{
|
||||
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||
|| $reservation->expires_at === null) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||
}
|
||||
}
|
||||
|
||||
private function expiration(): Carbon
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -42,6 +43,11 @@ class ReleaseCheckoutService
|
||||
): Purchase {
|
||||
$purchase = $this->lockPurchase($purchase);
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||
return $this->loadPurchase($purchase);
|
||||
@@ -66,6 +72,18 @@ 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, [
|
||||
@@ -112,17 +130,17 @@ class ReleaseCheckoutService
|
||||
}
|
||||
|
||||
if ($cart->status === 'active') {
|
||||
$cartUpdate = [
|
||||
'current_purchase_id' => null,
|
||||
];
|
||||
if ($targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||
$cartUpdate['current_stock_reservation_id'] = null;
|
||||
}
|
||||
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update([
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
]);
|
||||
|
||||
if ($targetStatus === Purchase::STATUS_CANCELLED) {
|
||||
$this->reservations->syncCart($cart);
|
||||
}
|
||||
->update($cartUpdate);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -314,17 +314,10 @@ class StartCheckoutService
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$this->reservations->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
]);
|
||||
$this->reservations->releaseForPurchase(
|
||||
$currentPurchase,
|
||||
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||
);
|
||||
$cart->update([
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $cart;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
||||
@@ -113,6 +114,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
});
|
||||
$exceptions->render(function (StockReservationExpiredException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'stock_reservation.expired',
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
});
|
||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
|
||||
@@ -34,6 +34,9 @@ return [
|
||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||
'empty_bundle' => 'The bundle has no components.',
|
||||
'variant_required' => 'You must select a variant for this item.',
|
||||
'reservation_expired' => 'The stock reservation has expired. Abandon this cart to start a new one.',
|
||||
'reservation_not_expired' => 'The cart can only be restarted after its stock reservation expires.',
|
||||
'restarted' => 'The expired cart was abandoned. You can start a new one.',
|
||||
],
|
||||
'purchase' => [
|
||||
'expired' => 'The purchase has expired. Please start a new purchase.',
|
||||
|
||||
@@ -34,6 +34,9 @@ return [
|
||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||
'reservation_expired' => 'La reserva de stock venció. Abandoná este carrito para comenzar uno nuevo.',
|
||||
'reservation_not_expired' => 'El carrito sólo puede reiniciarse cuando su reserva de stock está vencida.',
|
||||
'restarted' => 'Carrito vencido abandonado. Podés comenzar uno nuevo.',
|
||||
],
|
||||
'purchase' => [
|
||||
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
||||
|
||||
Reference in New Issue
Block a user