refactor(reservations): unify expiration command

This commit is contained in:
2026-08-19 13:58:03 -03:00
parent 093e894cc3
commit 1dc4e29c69
6 changed files with 76 additions and 25 deletions

View File

@@ -34,4 +34,4 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
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.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las 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.

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
class ExpireStockReservationsService
{
public function __construct(
private readonly CheckoutService $checkout,
private readonly ExpireCartReservationsService $carts,
) {}
/**
* @return array{purchases: int, cart_items: int}
*/
public function expireOverdue(): array
{
return [
'purchases' => $this->checkout->expireOverduePurchases(),
'cart_items' => $this->carts->expireOverdue(),
];
}
}

View File

@@ -1,7 +1,6 @@
<?php
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@@ -10,24 +9,13 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Artisan::command('purchases:expire', function (): void {
$expiredCount = app(CheckoutService::class)
->expireOverduePurchases();
Artisan::command('reservations:expire', function (): void {
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
$this->info("Expired purchases: {$expiredCount}");
})->purpose('Release stock reservations from expired purchases');
$this->info("Expired purchases: {$expired['purchases']}");
$this->info("Expired cart items: {$expired['cart_items']}");
})->purpose('Release expired stock reservations from purchases and abandoned carts');
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')
Schedule::command('reservations:expire')
->everyMinute()
->withoutOverlapping();

View File

@@ -117,13 +117,15 @@ class CartControllerTest extends TestCase
$cartId = $response->json('data.id');
$cartItemId = $response->json('data.items.0.id');
$this->artisan('carts:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
$this->travel(31)->minutes();
$this->artisan('carts:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 1')
->assertSuccessful();
@@ -146,7 +148,8 @@ class CartControllerTest extends TestCase
'expires_at' => null,
]);
$this->artisan('carts:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();

View File

@@ -850,8 +850,9 @@ class StorePurchaseTest extends TestCase
$this->travel(31)->minutes();
$this->artisan('purchases:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 1')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
$this->assertDatabaseHas('compras', [
@@ -877,8 +878,9 @@ class StorePurchaseTest extends TestCase
'deleted_at' => null,
]);
$this->artisan('purchases:expire')
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
use Tests\TestCase;
class ExpireStockReservationsServiceTest extends TestCase
{
public function test_it_expires_purchases_before_abandoned_cart_items(): void
{
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')
->once()
->ordered()
->andReturn(2);
$carts = \Mockery::mock(ExpireCartReservationsService::class);
$carts->shouldReceive('expireOverdue')
->once()
->ordered()
->andReturn(3);
$result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
$this->assertSame([
'purchases' => 2,
'cart_items' => 3,
], $result);
}
}