feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates

This commit is contained in:
2026-09-17 10:27:27 -03:00
parent 1269ebcda0
commit de08ee4ac7
5 changed files with 265 additions and 1 deletions

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(Tenant $tenant, Collection $eventDateIds): void
{
DB::transaction(function () use ($tenant, $eventDateIds): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -19,6 +19,8 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
@@ -28,6 +29,7 @@ class EventService
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {}
public function forTenant(Tenant $tenant): Tenant
@@ -125,9 +127,11 @@ class EventService
]);
}
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$this->affectedDateIds($tenant, $source),
$affectedDateIds,
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);

View File

@@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
@@ -421,6 +422,43 @@ class AdminAppEventControllerTest extends TestCase
);
}
public function test_rescheduling_invalidates_reserved_carts_before_replacing_variants(): void
{
Event::fake([EventDateRescheduled::class]);
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
$source = $tenant->eventDates()->create([
'date' => '2027-10-09', 'time_start' => '09:00', 'time_end' => '18:30',
]);
$variant = $this->createVariant($tenant, $source->id);
$variant->inventory->update(['real_stock' => 5, 'reserved_stock' => 2]);
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => now()->addHour(),
]);
StockReservationLine::query()->create([
'stock_reservation_id' => $reservation->id, 'inventory_id' => $variant->inventory_id,
'quantity' => 2, 'tracks_inventory' => true,
]);
$admin = $this->createAdminAppUser($tenant);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo, 'user_id' => $admin->id,
'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER,
'current_stock_reservation_id' => $reservation->id,
]);
$cart->items()->create([
'catalog_item_id' => $variant->catalog_item_id, 'variant_id' => $variant->id, 'cantidad' => 2,
]);
Sanctum::actingAs($admin);
$this->postJson("/api/v1/adminapp/tenant/event-dates/{$source->id}/reschedule", [
'date' => '2027-10-20',
])->assertOk();
$this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status);
$this->assertSame(StockReservation::STATUS_RELEASED, $reservation->fresh()->status);
$this->assertSame(0, $variant->fresh()->replacement->inventory->reserved_stock);
$this->getJson('/api/tenants/acme/cart')->assertOk()->assertJsonCount(0, 'data.items');
}
public function test_rescheduling_reuses_an_equivalent_destination_variant(): void
{
Event::fake([EventDateRescheduled::class]);

View File

@@ -0,0 +1,164 @@
<?php
namespace Tests\Unit\Cart;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class InvalidateEventDateCartsServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Minimal service schema, still using the guarded SQLite :memory: connection.
Schema::create('carritos', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo');
$table->string('status');
$table->unsignedBigInteger('current_purchase_id')->nullable();
$table->unsignedBigInteger('current_stock_reservation_id')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('carrito_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('cart_id');
$table->unsignedBigInteger('variant_id');
});
Schema::create('variantes', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('event_date_id')->nullable();
$table->softDeletes();
});
Schema::create('event_dates', function (Blueprint $table): void {
$table->id();
$table->date('date')->nullable();
$table->time('time_start')->nullable();
});
Schema::create('variant_event_dates', function (Blueprint $table): void {
$table->unsignedBigInteger('variant_id');
$table->unsignedBigInteger('event_date_id');
});
Schema::create('compras', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('stock_reservation_id');
});
Schema::create('inventories', function (Blueprint $table): void {
$table->id();
$table->integer('real_stock')->default(10);
$table->integer('reserved_stock')->default(2);
$table->integer('sold_units')->default(0);
$table->integer('refunded_units')->default(0);
});
Schema::create('stock_reservations', function (Blueprint $table): void {
$table->id();
$table->string('status');
$table->timestamp('expires_at')->nullable();
$table->timestamp('released_at')->nullable();
$table->timestamp('expired_at')->nullable();
$table->string('release_reason')->nullable();
$table->timestamps();
});
Schema::create('stock_reservation_lines', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('stock_reservation_id');
$table->unsignedBigInteger('inventory_id');
$table->integer('quantity');
});
DB::table('event_dates')->insert([['id' => 1], ['id' => 2]]);
DB::table('variantes')->insert([
['id' => 1, 'event_date_id' => 1],
['id' => 2, 'event_date_id' => 2],
['id' => 3, 'event_date_id' => null],
]);
DB::table('variant_event_dates')->insert(['variant_id' => 3, 'event_date_id' => 1]);
}
public function test_invalidates_whole_cart_and_releases_all_its_stock_only_once(): void
{
$cart = $this->cart(1);
$otherInventory = DB::table('inventories')->insertGetId([]);
DB::table('carrito_items')->insert(['cart_id' => $cart->id, 'variant_id' => 2]);
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $cart->current_stock_reservation_id,
'inventory_id' => $otherInventory,
'quantity' => 2,
]);
$reservationId = $cart->current_stock_reservation_id;
$this->invalidate();
$this->invalidate();
$this->assertSame(Cart::STATUS_EXPIRED, $cart->fresh()->status);
$this->assertNull($cart->fresh()->current_stock_reservation_id);
$this->assertSame(0, (int) Inventory::query()->sum('reserved_stock'));
$this->assertSame(20, (int) Inventory::query()->sum('real_stock'));
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId, 'status' => StockReservation::STATUS_RELEASED,
'release_reason' => 'event_date_rescheduled',
]);
}
public function test_preserves_other_dates_tenants_and_purchase_reservations(): void
{
$otherDate = $this->cart(2);
$otherTenant = $this->cart(1, 'other');
$purchaseCart = $this->cart(1);
DB::table('compras')->insert(['stock_reservation_id' => $purchaseCart->current_stock_reservation_id]);
$currentPurchaseCart = $this->cart(1);
$currentPurchaseCart->update(['current_purchase_id' => 99]);
$affected = $this->cart(3);
$this->invalidate();
foreach ([$otherDate, $otherTenant, $purchaseCart, $currentPurchaseCart] as $cart) {
$this->assertSame(Cart::STATUS_ACTIVE, $cart->fresh()->status);
$this->assertSame(StockReservation::STATUS_ACTIVE, $cart->fresh()->currentStockReservation->status);
}
$this->assertSame(Cart::STATUS_EXPIRED, $affected->fresh()->status);
$this->assertSame(8, (int) Inventory::query()->sum('reserved_stock'));
}
public function test_rolls_back_invalidation_when_the_date_change_fails(): void
{
$cart = $this->cart(1);
DB::beginTransaction();
$this->invalidate();
DB::rollBack();
$this->assertSame(Cart::STATUS_ACTIVE, $cart->fresh()->status);
$this->assertSame(StockReservation::STATUS_ACTIVE, $cart->fresh()->currentStockReservation->status);
$this->assertSame(2, (int) Inventory::query()->sum('reserved_stock'));
}
private function invalidate(): void
{
app(InvalidateEventDateCartsService::class)->invalidate(new Tenant(['codigo' => 'acme']), collect([1]));
}
private function cart(int $variantId, string $tenantCode = 'acme'): Cart
{
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => now()->addHour(),
]);
$cart = Cart::query()->create([
'tenant_codigo' => $tenantCode, 'status' => Cart::STATUS_ACTIVE,
'current_stock_reservation_id' => $reservation->id,
]);
DB::table('carrito_items')->insert(['cart_id' => $cart->id, 'variant_id' => $variantId]);
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $reservation->id,
'inventory_id' => DB::table('inventories')->insertGetId([]),
'quantity' => 2,
]);
return $cart;
}
}