diff --git a/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php b/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php new file mode 100644 index 00000000..fdae5190 --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php @@ -0,0 +1,291 @@ + + */ + public function reservations(User $user, array $filters = []): LengthAwarePaginator + { + return $this->reservationsQuery($user, $filters) + ->paginate(perPage: $filters['per_page'] ?? 15, page: $filters['page'] ?? 1) + ->withQueryString(); + } + + /** + * @param array{catalog_item_id?: int, variant_id?: int, tipo_pago?: string|null} $filters + * @return Collection + */ + public function reservationsForExport(User $user, array $filters = []): Collection + { + return $this->reservationsQuery($user, $filters)->get(); + } + + public function reservationTicket(User $user, int $reservationId, ?int $catalogItemId = null): Ticket + { + $reservation = $this->reservationsQuery($user, ['catalog_item_id' => $catalogItemId]) + ->whereKey($reservationId)->whereNotNull('ticket_id') + ->with(['ticket' => fn ($query) => $query->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + ])])->firstOrFail(); + + return $reservation->ticket; + } + + public function cancel(User $user, int $reservationId, ?int $catalogItemId = null): void + { + DB::transaction(function () use ($user, $reservationId, $catalogItemId): void { + $reservation = $this->reservationsQuery($user, ['catalog_item_id' => $catalogItemId]) + ->whereKey($reservationId)->lockForUpdate()->firstOrFail(); + + if ($reservation->ticket_id !== null) { + $ticket = Ticket::query()->whereKey($reservation->ticket_id)->lockForUpdate()->firstOrFail(); + if (! $ticket->can_cancel()) { + throw ValidationException::withMessages([ + 'status' => 'El ticket debe estar activo para poder cancelar la reserva.', + ]); + } + $ticket->markAsCancelled(); + $ticket->save(); + } + + if ($reservation->inventory_id !== null) { + $inventory = Inventory::query()->whereKey($reservation->inventory_id)->lockForUpdate()->firstOrFail(); + $inventory->releaseEntry(1); + } + $reservation->delete(); + }, 3); + } + + /** + * Each row reserves one unit. Prices and inventory are resolved on the server. + * + * @param list $rows + * @return Collection + */ + public function reserve(User $user, string $key, array $rows): Collection + { + $validated = Validator::make(['idempotency_key' => $key, 'rows' => $rows], [ + 'idempotency_key' => ['required', 'uuid'], + 'rows' => ['required', 'array', 'list', 'min:1', 'max:100'], + 'rows.*' => ['required', 'array:catalog_item_id,variant_id,tipo_pago'], + 'rows.*.catalog_item_id' => ['required', 'integer', 'min:1'], + 'rows.*.variant_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'rows.*.tipo_pago' => ['required', Rule::enum(EntryReservationPaymentType::class)], + ])->validate(); + $rows = array_map(fn (array $row): array => [ + 'catalog_item_id' => (int) $row['catalog_item_id'], + 'variant_id' => isset($row['variant_id']) ? (int) $row['variant_id'] : null, + 'tipo_pago' => $row['tipo_pago'], + ], $validated['rows']); + $hash = $this->requestHash($rows); + + return DB::transaction(function () use ($user, $key, $rows, $hash): Collection { + User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $tenant = $user->tenant()->firstOrFail(); + $batch = ReservationBatch::query()->where('user_id', $user->id) + ->where('idempotency_key', $key)->lockForUpdate()->first(); + if ($batch !== null) { + abort_unless($batch->tenant_code === $tenant->codigo && $this->matchesBatch($batch, $rows, $hash), + 409, 'La clave de envío ya fue utilizada con otra selección.'); + + return $batch->reservations()->with(['catalogItem', 'variant', 'ticket'])->orderBy('id')->get(); + } + + $items = CatalogItem::query()->forTenantCatalog($tenant) + ->whereKey(array_column($rows, 'catalog_item_id'))->orderBy('id')->lockForUpdate()->get()->keyBy('id'); + $variants = Variant::query()->whereIn('catalog_item_id', $items->keys()) + ->whereKey(array_filter(array_column($rows, 'variant_id'))) + ->orderBy('id')->lockForUpdate()->get()->keyBy('id'); + $variants->load(['eventDates', 'eventDate']); + $inventoryIds = $items->pluck('inventory_id')->merge($variants->pluck('inventory_id'))->filter()->unique(); + $inventories = Inventory::query()->whereKey($inventoryIds)->orderBy('id')->lockForUpdate()->get()->keyBy('id'); + $selections = []; + $requirements = []; + $errors = []; + + foreach ($rows as $index => $row) { + $item = $items->get($row['catalog_item_id']); + $variant = $row['variant_id'] !== null ? $variants->get($row['variant_id']) : null; + $field = $this->selectionField($row, $index); + if ($item === null || ! $item->has_tickets || ! $item->isSaleOpen()) { + $errors[$field] = 'El ítem no está disponible para reservar tickets en este catálogo.'; + + continue; + } + if ($item->isBundle()) { + $errors[$field] = 'Seleccioná el ítem que emite el ticket; los paquetes requieren reservar sus componentes.'; + + continue; + } + if ($row['variant_id'] !== null) { + if ($variant === null || $variant->catalog_item_id !== $item->id) { + $errors[$field] = 'La variante no pertenece al ítem del catálogo seleccionado.'; + + continue; + } + $variant->setRelation('catalogItem', $item); + if (! $variant->isSellable()) { + $errors[$field] = 'La variante ya no está disponible.'; + + continue; + } + } elseif ($item->variants()->exists()) { + $errors["rows.{$index}.variant_id"] = 'Seleccioná una variante del ítem del catálogo.'; + + continue; + } + + $selection = $variant ?? $item; + $inventory = $inventories->get($selection->inventory_id); + $tracked = $item->inventory_policy !== InventoryPolicy::Unlimited; + if ($inventory === null && ($selection->inventory_id !== null || $tracked)) { + $errors[$field] = 'La selección no tiene un inventario disponible.'; + + continue; + } + $selection->setRelation('inventory', $inventory); + $selections[$index] = ['item' => $item, 'variant' => $variant, 'inventory' => $inventory]; + if ($inventory !== null) { + $requirement = $requirements[$inventory->id] ?? ['quantity' => 0, 'tracked' => false]; + $requirements[$inventory->id] = [ + 'quantity' => $requirement['quantity'] + 1, + 'tracked' => $requirement['tracked'] || $tracked, + ]; + } + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + foreach ($requirements as $inventoryId => $requirement) { + if ($requirement['tracked'] && $inventories[$inventoryId]->availableStock() < $requirement['quantity']) { + foreach ($selections as $index => $selection) { + if ($selection['inventory']?->id === $inventoryId) { + $errors[$this->selectionField($rows[$index], $index)] = 'No hay stock suficiente para reservar la selección.'; + } + } + } + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $batch = ReservationBatch::query()->create([ + 'user_id' => $user->id, 'tenant_code' => $tenant->codigo, + 'idempotency_key' => $key, 'request_hash' => $hash, + ]); + foreach ($requirements as $inventoryId => $requirement) { + $inventories[$inventoryId]->reserveEntry($requirement['quantity'], $requirement['tracked']); + } + + $reservations = collect(); + foreach ($selections as $index => $selection) { + $item = $selection['item']; + $variant = $selection['variant']; + try { + $ticket = $this->tickets->generate($item, $user, 1, $variant?->id)->sole(); + } catch (TicketGenerationException $exception) { + throw ValidationException::withMessages([ + $this->selectionField($rows[$index], $index) => 'No se pudo emitir el ticket. '.$exception->getMessage(), + ]); + } + $payment = EntryReservationPaymentType::from($rows[$index]['tipo_pago']); + $reservation = EntryReservation::query()->create([ + 'batch_id' => $batch->id, 'ticket_id' => $ticket->id, + 'catalog_item_id' => $variant?->catalog_item_id ?? $item->id, + 'variant_id' => $variant?->id, 'inventory_id' => $selection['inventory']?->id, + 'fecha_reserva' => now(), 'tipo_pago' => $payment, + 'importe' => $payment === EntryReservationPaymentType::Free ? 0 : ($variant ?? $item)->getPrice(), + ]); + $reservations->push($reservation->setRelation('ticket', $ticket) + ->setRelation('catalogItem', $item)->setRelation('variant', $variant)); + } + + return $reservations; + }, 3); + } + + /** @param array $filters */ + private function reservationsQuery(User $user, array $filters = []): Builder + { + abort_unless($user->tenant_codigo !== null && $user->tenant_codigo !== '', 403); + + return EntryReservation::query() + ->whereHas('catalogItem', fn (Builder $query) => $query->where('tenant_code', $user->tenant_codigo)) + ->when($filters['catalog_item_id'] ?? null, fn (Builder $query, int $id) => $query->where('catalog_item_id', $id)) + ->when($filters['variant_id'] ?? null, fn (Builder $query, int $id) => $query->where('variant_id', $id)) + ->when($filters['tipo_pago'] ?? null, fn (Builder $query, string $type) => $query->where('tipo_pago', $type)) + ->with(['catalogItem', 'variant']) + ->orderByDesc('fecha_reserva')->orderByDesc('id'); + } + + /** @param array{variant_id: int|null} $row */ + private function selectionField(array $row, int $index): string + { + return "rows.{$index}.".($row['variant_id'] !== null ? 'variant_id' : 'catalog_item_id'); + } + + /** @param list $rows */ + private function requestHash(array $rows): string + { + $normalized = collect($rows)->sortBy([ + ['catalog_item_id', 'asc'], ['variant_id', 'asc'], ['tipo_pago', 'asc'], + ])->values()->all(); + + return hash('sha256', json_encode($normalized, JSON_THROW_ON_ERROR)); + } + + /** @param list $rows */ + private function matchesBatch(ReservationBatch $batch, array $rows, string $hash): bool + { + if (hash_equals($batch->request_hash, $hash)) { + return true; + } + if (collect($rows)->contains(fn (array $row) => $row['variant_id'] === null)) { + return false; + } + + // Older batches fingerprinted variant and payment only. Check the persisted + // catalog IDs too, so compatibility cannot replay a different catalog item. + $legacyRows = collect($rows)->map(fn (array $row) => [ + 'variant_id' => $row['variant_id'], 'tipo_pago' => $row['tipo_pago'], + ])->sortBy('variant_id')->values()->all(); + $legacyHash = hash('sha256', json_encode($legacyRows, JSON_THROW_ON_ERROR)); + if (! hash_equals($batch->request_hash, $legacyHash)) { + return false; + } + $storedRows = $batch->reservations()->withTrashed()->get()->map(fn (EntryReservation $reservation) => [ + 'catalog_item_id' => $reservation->catalog_item_id, + 'variant_id' => $reservation->variant_id, + 'tipo_pago' => $reservation->tipo_pago->value, + ])->all(); + + return hash_equals($this->requestHash($storedRows), $hash); + } +} diff --git a/tests/Feature/Ticket/EntryReservationServiceTest.php b/tests/Feature/Ticket/EntryReservationServiceTest.php new file mode 100644 index 00000000..e62f6109 --- /dev/null +++ b/tests/Feature/Ticket/EntryReservationServiceTest.php @@ -0,0 +1,469 @@ +id(); + $table->string('tenant_codigo'); + $table->softDeletes(); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->string('codigo')->unique(); + $table->unsignedBigInteger('active_event_id')->nullable(); + }); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('slug'); + $table->string('nombre')->default('Entrada'); + $table->string('type')->default('standard'); + $table->unsignedBigInteger('inventory_id')->nullable(); + $table->string('inventory_policy'); + $table->decimal('precio', 10, 2); + $table->timestamp('sales_end_at')->nullable(); + $table->softDeletes(); + $table->boolean('has_tickets')->default(true); + $table->unsignedBigInteger('event_id')->nullable(); + }); + Schema::create('inventories', function (Blueprint $table): void { + $table->id(); + foreach (['real_stock', 'reserved_stock', 'sold_units', 'refunded_units'] as $column) { + $table->integer($column)->default(0); + } + }); + Schema::create('variantes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('catalog_item_id'); + $table->foreignId('inventory_id'); + $table->decimal('precio', 10, 2); + $table->unsignedBigInteger('event_date_id')->nullable(); + $table->unsignedBigInteger('replaced_by_variant_id')->nullable(); + $table->timestamp('sales_disabled_at')->nullable(); + $table->softDeletes(); + }); + Schema::create('event_dates', function (Blueprint $table): void { + $table->id(); + $table->date('date'); + $table->time('time_start')->nullable(); + $table->unsignedBigInteger('rescheduled_to_event_date_id')->nullable(); + $table->timestamp('suspended_at')->nullable(); + }); + Schema::create('attribute', function (Blueprint $table): void { + $table->id(); + $table->string('codigo'); + $table->string('type')->default('string'); + }); + Schema::create('item_attributes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('catalog_item_id'); + $table->foreignId('attribute_id'); + $table->boolean('allow_multi_select')->default(false); + $table->integer('sort_order')->default(0); + }); + Schema::create('attribute_options', function (Blueprint $table): void { + $table->id(); + $table->foreignId('attribute_id'); + $table->string('value'); + $table->string('label'); + $table->integer('sort_order')->default(0); + }); + Schema::create('variant_values', function (Blueprint $table): void { + $table->id(); + $table->foreignId('variant_id'); + $table->foreignId('item_attribute_id'); + $table->string('value'); + }); + Schema::create('variant_event_dates', function (Blueprint $table): void { + $table->foreignId('variant_id'); + $table->foreignId('event_date_id'); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('user_id'); + $table->foreignId('source_variant_id')->nullable(); + $table->string('tenant_code')->nullable(); + $table->uuid('ticket')->nullable()->unique(); + $table->unsignedBigInteger('source_catalog_item_id')->nullable(); + $table->unsignedBigInteger('source_purchase_item_id')->nullable(); + $table->unsignedBigInteger('event_id')->nullable(); + $table->timestamp('used_at')->nullable(); + $table->timestamp('disabled_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('refunded_at')->nullable(); + }); + Schema::create('value_changes', function (Blueprint $table): void { + $table->id(); + $table->morphs('trackable'); + $table->string('tenant_code'); + $table->string('attribute'); + $table->text('old_value')->nullable(); + $table->text('new_value')->nullable(); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->foreignId('user_id')->nullable(); + }); + (require database_path('migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php'))->up(); + (require database_path('migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php'))->up(); + (require database_path('migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php'))->up(); + (require database_path('migrations/2026_10_02_000000_generalize_ticket_reservations.php'))->up(); + DB::table('tenants')->insert(['codigo' => 'ticket_shop']); + DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'ticket_shop']); + DB::table('catalog_items')->insert([ + 'id' => 1, 'tenant_code' => 'ticket_shop', 'slug' => 'entrada', + 'inventory_policy' => 'tracked', 'precio' => 100, + ]); + foreach ([1, 2] as $id) { + DB::table('inventories')->insert(['id' => $id, 'real_stock' => 3]); + DB::table('variantes')->insert(['id' => $id, 'catalog_item_id' => 1, 'inventory_id' => $id, 'precio' => 250]); + } + DB::table('tenants')->insert(['codigo' => 'other']); + DB::table('users')->insert(['id' => 2, 'tenant_codigo' => 'other']); + DB::table('inventories')->insert(['id' => 3, 'real_stock' => 3]); + DB::table('catalog_items')->insert([ + ['id' => 2, 'tenant_code' => 'ticket_shop', 'slug' => 'general', 'inventory_policy' => 'tracked', 'inventory_id' => 3, 'precio' => 700], + ['id' => 3, 'tenant_code' => 'other', 'slug' => 'other', 'inventory_policy' => 'unlimited', 'inventory_id' => null, 'precio' => 900], + ['id' => 4, 'tenant_code' => 'ticket_shop', 'slug' => 'unlimited', 'inventory_policy' => 'unlimited', 'inventory_id' => null, 'precio' => 500], + ]); + foreach (['tipo' => 'NORMAL', 'sector' => 'A', 'fila' => '3', 'asiento' => '17'] as $code => $value) { + $attributeId = DB::table('attribute')->insertGetId(['codigo' => $code]); + $itemAttributeId = DB::table('item_attributes')->insertGetId([ + 'catalog_item_id' => 1, + 'attribute_id' => $attributeId, + ]); + DB::table('attribute_options')->insert([ + 'attribute_id' => $attributeId, + 'value' => $value, + 'label' => $value, + ]); + foreach ([1, 2] as $variantId) { + DB::table('variant_values')->insert([ + 'variant_id' => $variantId, 'item_attribute_id' => $itemAttributeId, 'value' => $value, + ]); + } + } + } + + private function service(bool $failSecond = false): EntryReservationService + { + $generator = Mockery::mock(TicketGeneratorService::class); + $generator->shouldReceive('generate')->andReturnUsing(function ($item, $user, $quantity, $variantId) use ($failSecond) { + if ($failSecond && $item->id === 2) { + throw new \RuntimeException('Ticket generation failed'); + } + + return collect([Ticket::create([ + 'tenant_code' => $item->tenant_code, 'user_id' => $user->id, + 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variantId, + 'ticket' => (string) Str::uuid(), + ])]); + }); + + return new EntryReservationService($generator); + } + + private function rows(): array + { + return [ + ['catalog_item_id' => 1, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], + ['catalog_item_id' => 2, 'tipo_pago' => 'otro_metodo'], + ]; + } + + public function test_reserves_multiple_items_with_and_without_variants_and_replays_the_batch(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $result = $service->reserve($user, $key, $this->rows()); + $this->assertSame([1, 2], $result->pluck('catalog_item_id')->all()); + $this->assertSame([1, null], $result->pluck('variant_id')->all()); + $this->assertSame(['0.00', '700.00'], $result->pluck('importe')->all()); + $replay = $service->reserve($user, $key, array_reverse($this->rows())); + $this->assertSame($result->pluck('id')->all(), $replay->pluck('id')->all()); + $this->assertDatabaseCount('tickets', 2); + $this->assertDatabaseCount('entry_reservations', 2); + $this->assertDatabaseCount('reservation_batches', 1); + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + $this->assertSame(1, Inventory::findOrFail(3)->entry_reserved_stock); + } + + public function test_repeated_variants_use_stock_instead_of_assuming_a_unique_seat(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $row = $this->rows()[0]; + $service->reserve($user, (string) Str::uuid(), [$row, $row]); + $service->reserve($user, (string) Str::uuid(), [$row]); + $this->assertSame(3, Inventory::findOrFail(1)->entry_reserved_stock); + $this->expectException(ValidationException::class); + $service->reserve($user, (string) Str::uuid(), [$row]); + } + + public function test_shared_inventory_is_checked_for_the_entire_batch(): void + { + DB::table('catalog_items')->where('id', 2)->update(['inventory_id' => 1]); + DB::table('inventories')->where('id', 1)->update(['real_stock' => 1]); + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected insufficient shared stock'); + } catch (ValidationException $error) { + $this->assertSame(['rows.0.variant_id', 'rows.1.catalog_item_id'], array_keys($error->errors())); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('reservation_batches', 0); + $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); + } + + public function test_ticket_failure_rolls_back_the_entire_batch(): void + { + try { + $this->service(true)->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected ticket failure'); + } catch (\RuntimeException $error) { + $this->assertSame('Ticket generation failed', $error->getMessage()); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('entry_reservations', 0); + $this->assertDatabaseCount('reservation_batches', 0); + $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); + } + + public function test_an_item_from_another_tenant_is_rejected(): void + { + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ + ['catalog_item_id' => 3, 'tipo_pago' => 'sin_cargo'], + ]); + } + + public function test_a_variant_from_another_item_is_rejected(): void + { + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ + ['catalog_item_id' => 2, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], + ]); + } + + public function test_a_variant_is_required_when_the_item_has_variants(): void + { + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ + ['catalog_item_id' => 1, 'tipo_pago' => 'sin_cargo'], + ]); + } + + public function test_disabled_variants_and_items_without_tickets_are_rejected(): void + { + DB::table('variantes')->where('id', 1)->update(['sales_disabled_at' => now()]); + DB::table('catalog_items')->where('id', 2)->update(['has_tickets' => false]); + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + $this->fail('Expected unavailable selection'); + } catch (ValidationException $error) { + $this->assertSame(['rows.0.variant_id', 'rows.1.catalog_item_id'], array_keys($error->errors())); + } + $this->assertDatabaseCount('entry_reservations', 0); + } + + public function test_the_active_event_limits_the_catalog_for_new_reservations(): void + { + DB::table('tenants')->where('codigo', 'ticket_shop')->update(['active_event_id' => 10]); + DB::table('catalog_items')->where('id', 1)->update(['event_id' => 10]); + DB::table('catalog_items')->where('id', 2)->update(['event_id' => 20]); + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + } + + public function test_unlimited_items_can_emit_tickets_without_inventory(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [ + ['catalog_item_id' => 4, 'tipo_pago' => 'otro_metodo'], + ])->sole(); + $this->assertNull($reservation->inventory_id); + $this->assertNull($reservation->variant_id); + $this->assertSame('500.00', $reservation->importe); + $service->cancel($user, $reservation->id); + $this->assertSoftDeleted('entry_reservations', ['id' => $reservation->id]); + } + + public function test_filters_and_exports_are_scoped_by_tenant_and_catalog_item(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + $service->reserve(User::findOrFail(2), (string) Str::uuid(), [ + ['catalog_item_id' => 3, 'tipo_pago' => 'sin_cargo'], + ]); + $this->assertSame(2, $service->reservations($user)->total()); + $this->assertSame(1, $service->reservations($user, ['catalog_item_id' => 2, 'tipo_pago' => 'otro_metodo'])->total()); + $this->assertSame(1, $service->reservations($user, ['variant_id' => 1, 'per_page' => 1])->total()); + $this->assertSame(2, $service->reservationsForExport($user)->count()); + $this->assertSame(2, $service->reservationsForExport($user, ['catalog_item_id' => 2])->sole()->catalog_item_id); + $this->assertSame(0, $service->reservations($user, ['catalog_item_id' => 3])->total()); + } + + public function test_cancel_releases_stock_and_cancels_the_ticket_without_a_variant(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[1]])->sole(); + $service->cancel($user, $reservation->id, 2); + $this->assertSoftDeleted('entry_reservations', ['id' => $reservation->id]); + $this->assertNotNull(Ticket::findOrFail($reservation->ticket_id)->cancelled_at); + $this->assertSame(0, Inventory::findOrFail(3)->entry_reserved_stock); + $this->assertSame(0, $service->reservations($user)->total()); + } + + public function test_another_tenant_cannot_cancel_a_reservation(): void + { + $service = $this->service(); + $reservation = $service->reserve(User::findOrFail(1), (string) Str::uuid(), [$this->rows()[0]])->sole(); + $this->expectException(ModelNotFoundException::class); + $service->cancel(User::findOrFail(2), $reservation->id); + } + + public function test_cancellation_can_be_restricted_to_one_catalog_item_by_an_adapter(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); + $this->expectException(ModelNotFoundException::class); + $service->cancel($user, $reservation->id, 2); + } + + public function test_cancellation_does_not_release_stock_for_a_used_ticket(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); + Ticket::whereKey($reservation->ticket_id)->update(['used_at' => now()]); + try { + $service->cancel($user, $reservation->id); + $this->fail('Expected active ticket requirement'); + } catch (ValidationException $error) { + $this->assertArrayHasKey('status', $error->errors()); + } + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + $this->assertDatabaseHas('entry_reservations', ['id' => $reservation->id, 'deleted_at' => null]); + } + + public function test_a_reused_key_with_a_different_item_is_rejected(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $service->reserve($user, $key, [$this->rows()[1]]); + try { + $service->reserve($user, $key, [['catalog_item_id' => 4, 'tipo_pago' => 'otro_metodo']]); + $this->fail('Expected idempotency conflict'); + } catch (HttpException $error) { + $this->assertSame(409, $error->getStatusCode()); + } + $this->assertDatabaseCount('tickets', 1); + } + + public function test_clients_cannot_supply_the_price(): void + { + $this->expectException(ValidationException::class); + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ + [...$this->rows()[0], 'importe' => 10], + ]); + } + + public function test_old_variant_only_batch_hashes_replay_including_cancelled_history(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $rows = [ + ['catalog_item_id' => 1, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], + ['catalog_item_id' => 1, 'variant_id' => 2, 'tipo_pago' => 'otro_metodo'], + ]; + $result = $service->reserve($user, $key, $rows); + $legacyRows = array_map(fn ($row) => ['variant_id' => $row['variant_id'], 'tipo_pago' => $row['tipo_pago']], $rows); + DB::table('reservation_batches')->update([ + 'request_hash' => hash('sha256', json_encode($legacyRows, JSON_THROW_ON_ERROR)), + ]); + $service->cancel($user, $result->first()->id); + $replay = $service->reserve($user, $key, array_reverse($rows)); + $this->assertSame($result->last()->id, $replay->sole()->id); + $this->assertDatabaseCount('tickets', 2); + $this->assertDatabaseCount('reservation_batches', 1); + $this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock); + $this->assertSame(1, Inventory::findOrFail(2)->entry_reserved_stock); + } + + public function test_old_batch_hashes_cannot_replay_with_a_different_catalog_item(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $service->reserve($user, $key, [$this->rows()[0]]); + DB::table('reservation_batches')->update([ + 'request_hash' => hash('sha256', json_encode([ + ['variant_id' => 1, 'tipo_pago' => 'sin_cargo'], + ], JSON_THROW_ON_ERROR)), + ]); + try { + $service->reserve($user, $key, [ + ['catalog_item_id' => 2, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], + ]); + $this->fail('Expected legacy idempotency conflict'); + } catch (HttpException $error) { + $this->assertSame(409, $error->getStatusCode()); + } + $this->assertDatabaseCount('tickets', 1); + } + + public function test_ticket_download_selection_uses_tenant_and_catalog_item_scope(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[1]])->sole(); + $this->assertSame($reservation->ticket_id, $service->reservationTicket($user, $reservation->id, 2)->id); + $this->expectException(ModelNotFoundException::class); + $service->reservationTicket(User::findOrFail(2), $reservation->id); + } + + public function test_real_ticket_generator_links_each_catalog_item_and_optional_variant(): void + { + $validity = Mockery::mock(TicketValidityResolver::class); + $validity->shouldReceive('resolveVariant')->andReturn(ResolvedTicketValidity::unrestricted()); + $service = new EntryReservationService(new TicketGeneratorService($validity)); + $result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); + foreach ($result as $reservation) { + $this->assertDatabaseHas('tickets', [ + 'id' => $reservation->ticket_id, 'tenant_code' => 'ticket_shop', + 'source_catalog_item_id' => $reservation->catalog_item_id, + 'source_variant_id' => $reservation->variant_id, 'source_purchase_item_id' => null, + ]); + } + } +}