From 093e894cc34c9683a9a729594d46bccf00d629a9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 13:48:09 -0300 Subject: [PATCH] feat(cart): expire abandoned stock reservations --- .../ExpireCartReservationsService.php | 123 ++++++++++++++++++ app/Domains/Cart/documentacion/README.md | 3 + routes/console.php | 12 ++ tests/Feature/Cart/CartControllerTest.php | 49 +++++++ 4 files changed, 187 insertions(+) create mode 100644 app/Domains/Cart/Services/ExpireCartReservationsService.php diff --git a/app/Domains/Cart/Services/ExpireCartReservationsService.php b/app/Domains/Cart/Services/ExpireCartReservationsService.php new file mode 100644 index 0000000..d879994 --- /dev/null +++ b/app/Domains/Cart/Services/ExpireCartReservationsService.php @@ -0,0 +1,123 @@ +where('status', StockReservation::STATUS_ACTIVE) + ->whereNull('purchase_id') + ->whereNotNull('cart_item_id') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->where('cart_item_id', '>', $lastCartItemId) + ->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active')) + ->select('cart_item_id') + ->distinct() + ->orderBy('cart_item_id') + ->limit(500) + ->pluck('cart_item_id'); + + foreach ($cartItemIds as $cartItemId) { + $lastCartItemId = (int) $cartItemId; + + if ($this->expireCartItem($lastCartItemId)) { + $expiredItems++; + } + } + } while ($cartItemIds->count() === 500); + + return $expiredItems; + } + + private function expireCartItem(int $cartItemId): bool + { + /** @var CartItem|null $candidate */ + $candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId); + if ($candidate === null) { + return false; + } + + return DB::transaction(function () use ($candidate, $cartItemId): bool { + /** @var Cart|null $cart */ + $cart = Cart::query() + ->whereKey($candidate->cart_id) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + + if ($cart === null) { + return false; + } + + /** @var CartItem|null $cartItem */ + $cartItem = $cart->items() + ->whereKey($cartItemId) + ->lockForUpdate() + ->first(); + + if ($cartItem === null) { + return false; + } + + $reservations = StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->orderBy('inventory_id') + ->lockForUpdate() + ->get(); + + if ( + $reservations->isEmpty() + || $reservations->contains( + fn (StockReservation $reservation): bool => $reservation->purchase_id !== null + || $reservation->expires_at === null + || $reservation->expires_at->isFuture(), + ) + ) { + return false; + } + + $inventories = Inventory::query() + ->whereKey($reservations->pluck('inventory_id')) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + + foreach ($reservations as $reservation) { + $inventory = $inventories->get($reservation->inventory_id) + ?? throw new \InvalidArgumentException('No se encontro el inventario reservado.'); + + $inventory->release((int) $reservation->quantity); + $reservation->update([ + 'quantity' => 0, + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + 'released_at' => now(), + ]); + } + + $cartItem->delete(); + + if (! $cart->items()->exists()) { + $cart->update(['status' => 'expired']); + $cart->delete(); + } + + return true; + }); + } +} diff --git a/app/Domains/Cart/documentacion/README.md b/app/Domains/Cart/documentacion/README.md index a382b1f..afbe4e4 100644 --- a/app/Domains/Cart/documentacion/README.md +++ b/app/Domains/Cart/documentacion/README.md @@ -12,6 +12,7 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios ## Servicios - `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado. +- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos. - `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica. ## Endpoints @@ -32,3 +33,5 @@ Bajo `/tenants/{tenant:codigo}`: Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual. Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra. + +El comando `php artisan carts:expire` procesa reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. diff --git a/routes/console.php b/routes/console.php index 0c301ca..faa90aa 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ info("Expired purchases: {$expiredCount}"); })->purpose('Release stock reservations from expired purchases'); +Artisan::command('carts:expire', function (): void { + $expiredCount = app(ExpireCartReservationsService::class) + ->expireOverdue(); + + $this->info("Expired cart items: {$expiredCount}"); +})->purpose('Release expired stock reservations from abandoned carts'); + Schedule::command('purchases:expire') ->everyMinute() ->withoutOverlapping(); + +Schedule::command('carts:expire') + ->everyMinute() + ->withoutOverlapping(); diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 3438d57..8617c75 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -104,6 +104,55 @@ class CartControllerTest extends TestCase $this->travelBack(); } + public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 30); + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 10, '49.90'); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ])->assertOk(); + $cartId = $response->json('data.id'); + $cartItemId = $response->json('data.items.0.id'); + + $this->artisan('carts:expire') + ->expectsOutput('Expired cart items: 0') + ->assertSuccessful(); + + $this->travel(31)->minutes(); + + $this->artisan('carts:expire') + ->expectsOutput('Expired cart items: 1') + ->assertSuccessful(); + + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]); + $this->assertSoftDeleted('carritos', [ + 'id' => $cartId, + 'status' => 'expired', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $item->inventory_id, + 'cart_item_id' => null, + 'purchase_id' => null, + 'quantity' => 0, + 'status' => 'expired', + 'expires_at' => null, + ]); + + $this->artisan('carts:expire') + ->expectsOutput('Expired cart items: 0') + ->assertSuccessful(); + + $this->travelBack(); + } + public function test_it_filters_item_images_when_the_tenant_disables_them(): void { $tenant = $this->createTenant('acme');