diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index bb08b9e..368a77c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -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'), - ]); - } } diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index f0226f0..9c5f873 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -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; @@ -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'); @@ -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, diff --git a/app/Domains/Cart/documentacion/README.md b/app/Domains/Cart/documentacion/README.md index d2a5b6e..3cde77a 100644 --- a/app/Domains/Cart/documentacion/README.md +++ b/app/Domains/Cart/documentacion/README.md @@ -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. diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 9c5da2f..28488a7 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -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']); diff --git a/lang/en/api.php b/lang/en/api.php index e8dc73e..ddb6021 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -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.', diff --git a/lang/es/api.php b/lang/es/api.php index 21e90ea..7c8cdf3 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -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.", diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index ba17157..1a5696a 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -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')