refactor(stock): make expired reservations terminal
This commit is contained in:
@@ -86,4 +86,14 @@ class CartController extends Controller
|
|||||||
'message' => __('api.cart.item_removed'),
|
'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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Domains\Cart\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -97,6 +97,45 @@ class CartService
|
|||||||
return $this->loadCart($cart, $tenant);
|
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
|
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||||
{
|
{
|
||||||
$secure = (bool) config('session.secure');
|
$secure = (bool) config('session.secure');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('tenants/{tenant:codigo}')
|
Route::prefix('tenants/{tenant:codigo}')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('cart', [CartController::class, 'show']);
|
Route::get('cart', [CartController::class, 'show']);
|
||||||
|
Route::post('cart/restart', [CartController::class, 'restart']);
|
||||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||||
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
|
||||||
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Services;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\StockReservationLine;
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
@@ -43,13 +44,8 @@ class StockReservationService
|
|||||||
? null
|
? null
|
||||||
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||||
|
|
||||||
if ($reservation !== null
|
if ($reservation !== null) {
|
||||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
$this->assertUsableCartReservation($reservation);
|
||||||
&& $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 ($requirements === []) {
|
if ($requirements === []) {
|
||||||
@@ -67,7 +63,7 @@ class StockReservationService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
|
if ($reservation === null) {
|
||||||
$reservation = StockReservation::query()->create([
|
$reservation = StockReservation::query()->create([
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
'expires_at' => $this->expiration(),
|
'expires_at' => $this->expiration(),
|
||||||
@@ -175,9 +171,7 @@ class StockReservationService
|
|||||||
|
|
||||||
/** @var StockReservation $reservation */
|
/** @var StockReservation $reservation */
|
||||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
$this->assertUsableCartReservation($reservation);
|
||||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$linkedPurchase = Purchase::query()
|
$linkedPurchase = Purchase::query()
|
||||||
->where('stock_reservation_id', $reservation->getKey())
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
@@ -213,6 +207,9 @@ class StockReservationService
|
|||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
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);
|
$lines = $this->lockLines($reservation);
|
||||||
if ($lines->isEmpty()) {
|
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(
|
public function releaseCurrentCartReservation(
|
||||||
Cart $cart,
|
Cart $cart,
|
||||||
string $reason = self::REASON_CART_CHANGED,
|
string $reason = self::REASON_CART_CHANGED,
|
||||||
@@ -318,6 +359,9 @@ class StockReservationService
|
|||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
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]);
|
$reservation->update(['expires_at' => $expiresAt]);
|
||||||
});
|
});
|
||||||
@@ -420,9 +464,24 @@ class StockReservationService
|
|||||||
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Cart::query()
|
if ($status === StockReservation::STATUS_RELEASED) {
|
||||||
->where('current_stock_reservation_id', $reservation->getKey())
|
Cart::query()
|
||||||
->update(['current_stock_reservation_id' => null]);
|
->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
|
private function expiration(): Carbon
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ 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, [
|
||||||
@@ -118,17 +130,17 @@ class ReleaseCheckoutService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($cart->status === 'active') {
|
if ($cart->status === 'active') {
|
||||||
|
$cartUpdate = [
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
];
|
||||||
|
if ($targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||||
|
$cartUpdate['current_stock_reservation_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
Cart::query()
|
Cart::query()
|
||||||
->whereKey($cart->getKey())
|
->whereKey($cart->getKey())
|
||||||
->where('current_purchase_id', $purchase->getKey())
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
->update([
|
->update($cartUpdate);
|
||||||
'current_purchase_id' => null,
|
|
||||||
'current_stock_reservation_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($targetStatus === Purchase::STATUS_CANCELLED) {
|
|
||||||
$this->reservations->syncCart($cart);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,14 +319,6 @@ class StartCheckoutService
|
|||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'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;
|
return $cart;
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||||
'empty_bundle' => 'The bundle has no components.',
|
'empty_bundle' => 'The bundle has no components.',
|
||||||
'variant_required' => 'You must select a variant for this item.',
|
'variant_required' => 'You must select a variant for this item.',
|
||||||
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
|
'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' => [
|
'purchase' => [
|
||||||
'expired' => 'The purchase has expired. Please start a new purchase.',
|
'expired' => 'The purchase has expired. Please start a new purchase.',
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||||
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
|
'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' => [
|
'purchase' => [
|
||||||
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
||||||
|
|||||||
Reference in New Issue
Block a user