fix(cart): reconcile expired cart mutations
This commit is contained in:
parent
389373ad8c
commit
e63a03601a
|
|
@ -86,14 +86,4 @@ 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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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\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;
|
||||||
|
|
@ -26,7 +25,7 @@ class CartService
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
|
|
@ -98,45 +97,6 @@ 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' => Cart::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');
|
||||||
|
|
@ -279,7 +239,7 @@ class CartService
|
||||||
*/
|
*/
|
||||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$cart = $this->findCart($tenant, $identity);
|
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
throw new NotFoundHttpException('Cart not found.');
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
|
|
@ -293,14 +253,68 @@ class CartService
|
||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
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);
|
$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) {
|
if ($cart !== null) {
|
||||||
return $cart;
|
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 = [
|
$attributes = [
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => Cart::STATUS_ACTIVE,
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ Bajo `/tenants/{tenant:codigo}`:
|
||||||
- `POST /cart/items`.
|
- `POST /cart/items`.
|
||||||
- `PATCH /cart/items/{cartItem}`.
|
- `PATCH /cart/items/{cartItem}`.
|
||||||
- `DELETE /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
|
## 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.
|
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}')
|
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']);
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,7 @@ 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. Abandon this cart to start a new one.',
|
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
|
||||||
'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,9 +34,7 @@ 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ó. Abandoná este carrito para comenzar uno nuevo.',
|
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
|
||||||
'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.",
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ class CartControllerTest extends TestCase
|
||||||
$this->travelBack();
|
$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);
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
|
@ -208,10 +208,6 @@ class CartControllerTest extends TestCase
|
||||||
->expectsOutput('Expired cart reservations: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->postJson('/api/tenants/acme/cart/restart')
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors('cart');
|
|
||||||
|
|
||||||
$this->travel(31)->minutes();
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
|
|
@ -241,28 +237,13 @@ class CartControllerTest extends TestCase
|
||||||
'quantity' => 2,
|
'quantity' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/tenants/acme/cart')
|
$currentCart = $this->getJson('/api/tenants/acme/cart')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.id', $cartId)
|
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
|
||||||
->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.items', [])
|
->assertJsonPath('data.items', [])
|
||||||
->assertJsonMissingPath('data.stock_reservation')
|
->assertJsonMissingPath('data.stock_reservation')
|
||||||
->assertJsonMissingPath('data.current_stock_reservation_id');
|
->assertJsonMissingPath('data.current_stock_reservation_id');
|
||||||
$newCartId = $restart->json('data.id');
|
$newCartId = $currentCart->json('data.id');
|
||||||
|
|
||||||
$this->assertNotSame($cartId, $newCartId);
|
$this->assertNotSame($cartId, $newCartId);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
|
@ -276,6 +257,14 @@ class CartControllerTest extends TestCase
|
||||||
'current_stock_reservation_id' => null,
|
'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')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart reservations: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
|
|
@ -284,6 +273,88 @@ class CartControllerTest extends TestCase
|
||||||
$this->travelBack();
|
$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
|
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue