fix(cart): reconcile expired cart mutations
This commit is contained in:
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user