fix(cart): reconcile expired cart mutations

This commit is contained in:
2026-08-25 16:33:39 -03:00
parent 198ed400a9
commit 8a0f29bdae
7 changed files with 155 additions and 86 deletions

View File

@@ -86,14 +86,4 @@ 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'),
]);
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
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\Tenant\Models\Tenant;
use Illuminate\Http\Request;
@@ -26,7 +25,7 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
@@ -98,45 +97,6 @@ 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' => Cart::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');
@@ -279,7 +239,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -293,14 +253,68 @@ class CartService
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,

View File

@@ -22,7 +22,6 @@ Bajo `/tenants/{tenant:codigo}`:
- `POST /cart/items`.
- `PATCH /cart/items/{cartItem}`.
- `DELETE /cart/items/{cartItem}`.
- `POST /cart/restart`: abandona explícitamente un carrito cuya reserva venció y crea uno nuevo vacío.
## Contratos
@@ -36,4 +35,4 @@ Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técni
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. El carrito mantiene la referencia a esa reserva terminal: puede consultarse, pero no permite editar, cancelar ni iniciar checkout y nunca crea otra automáticamente. Para continuar, el cliente debe invocar explícitamente `POST /cart/restart`; el carrito anterior queda `abandoned` y el nuevo comienza vacío.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.

View File

@@ -6,7 +6,6 @@ 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']);