diff --git a/app/Domains/Cart/Services/InvalidateEventDateCartsService.php b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php new file mode 100644 index 0000000..5480113 --- /dev/null +++ b/app/Domains/Cart/Services/InvalidateEventDateCartsService.php @@ -0,0 +1,56 @@ + $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]); + } + }); + } +} diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 96d931b..bd6e0b5 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -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'; diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 8ea5d72..0662d09 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -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); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 72e27ee..e07ffe2 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -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]); diff --git a/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php new file mode 100644 index 0000000..a551c4e --- /dev/null +++ b/tests/Unit/Cart/InvalidateEventDateCartsServiceTest.php @@ -0,0 +1,164 @@ +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; + } +}