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']);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user