fix(cart): reconcile expired cart mutations

This commit is contained in:
2026-08-25 16:33:39 -03:00
parent 389373ad8c
commit e63a03601a
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']);

View File

@@ -34,9 +34,7 @@ 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.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
],
'purchase' => [
'expired' => 'The purchase has expired. Please start a new purchase.',

View File

@@ -34,9 +34,7 @@ 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.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
],
'purchase' => [
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",

View File

@@ -186,7 +186,7 @@ class CartControllerTest extends TestCase
$this->travelBack();
}
public function test_it_expires_abandoned_cart_reservations_without_deleting_the_cart(): void
public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
@@ -208,10 +208,6 @@ class CartControllerTest extends TestCase
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->postJson('/api/tenants/acme/cart/restart')
->assertUnprocessable()
->assertJsonValidationErrors('cart');
$this->travel(31)->minutes();
$this->artisan('reservations:expire')
@@ -241,28 +237,13 @@ class CartControllerTest extends TestCase
'quantity' => 2,
]);
$this->getJson('/api/tenants/acme/cart')
$currentCart = $this->getJson('/api/tenants/acme/cart')
->assertOk()
->assertJsonPath('data.id', $cartId)
->assertJsonPath('data.status', Cart::STATUS_EXPIRED);
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertUnprocessable()
->assertExactJson([
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
]);
$restart = $this->postJson('/api/tenants/acme/cart/restart')
->assertOk()
->assertJsonPath('code', 'cart.restarted')
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
->assertJsonPath('data.items', [])
->assertJsonMissingPath('data.stock_reservation')
->assertJsonMissingPath('data.current_stock_reservation_id');
$newCartId = $restart->json('data.id');
$newCartId = $currentCart->json('data.id');
$this->assertNotSame($cartId, $newCartId);
$this->assertDatabaseHas('carritos', [
@@ -276,6 +257,14 @@ class CartControllerTest extends TestCase
'current_stock_reservation_id' => null,
]);
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.id', $newCartId)
->assertJsonPath('data.items.0.cantidad', 1);
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart reservations: 0')
@@ -284,6 +273,88 @@ class CartControllerTest extends TestCase
$this->travelBack();
}
public function test_it_replaces_an_overdue_cart_before_the_expiration_job_runs(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 10, '49.90');
$original = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])->assertOk();
$originalCartId = $original->json('data.id');
$this->travel(31)->minutes();
$replacement = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
->assertJsonPath('data.items.0.cantidad', 1);
$this->assertNotSame($originalCartId, $replacement->json('data.id'));
$this->assertDatabaseHas('carritos', [
'id' => $originalCartId,
'status' => Cart::STATUS_ABANDONED,
]);
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'reserved_stock' => 1,
]);
$this->travelBack();
}
public function test_expired_cart_mutations_return_the_expiration_error_instead_of_not_found(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '49.90');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
$cartItemId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
])
->assertOk()
->json('data.items.0.id');
$this->travel(31)->minutes();
$expectedError = [
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
];
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 3,
])
->assertUnprocessable()
->assertExactJson($expectedError);
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 2,
'variant_id' => $secondVariant->id,
])
->assertUnprocessable()
->assertExactJson($expectedError);
$this->deleteJson("/api/tenants/acme/cart/items/{$cartItemId}")
->assertUnprocessable()
->assertExactJson($expectedError);
$this->travelBack();
}
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
{
$tenant = $this->createTenant('acme');