From f10237eb1f487b04c9fe65f500caa9f31bf6d7ca Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 2 Oct 2026 11:50:47 -0300 Subject: [PATCH] feat(ticket): implement DesfileEntryReservationController and related services for ticket reservations --- ... => DesfileEntryReservationController.php} | 16 +- .../DesfileEntryReservationPolicy.php | 31 +++ .../DesfileEntryReservationService.php | 123 +++++++++ .../Services/EntryReservationService.php | 241 ------------------ app/Domains/Ticketing/Desfile/routes/api.php | 14 +- .../Services/EntryReservationPolicy.php | 21 ++ .../Services/EntryReservationService.php | 10 +- .../Desfile/EntryReservationServiceTest.php | 133 +++++++++- .../Ticket/EntryReservationServiceTest.php | 40 +++ 9 files changed, 365 insertions(+), 264 deletions(-) rename app/Domains/Ticketing/Desfile/Controllers/{EntryReservationController.php => DesfileEntryReservationController.php} (84%) create mode 100644 app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationPolicy.php create mode 100644 app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationService.php delete mode 100644 app/Domains/Ticketing/Desfile/Services/EntryReservationService.php create mode 100644 app/Domains/Ticketing/Ticket/Services/EntryReservationPolicy.php diff --git a/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php b/app/Domains/Ticketing/Desfile/Controllers/DesfileEntryReservationController.php similarity index 84% rename from app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php rename to app/Domains/Ticketing/Desfile/Controllers/DesfileEntryReservationController.php index f9e1cc2b..abab64b4 100644 --- a/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php +++ b/app/Domains/Ticketing/Desfile/Controllers/DesfileEntryReservationController.php @@ -6,18 +6,18 @@ use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource; +use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationService; use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService; use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService; -use App\Domains\Ticketing\Desfile\Services\EntryReservationService; use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Http\Controllers\Controller; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\StreamedResponse; -class EntryReservationController extends Controller +class DesfileEntryReservationController extends Controller { - public function index(IndexEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection + public function index(IndexEntryReservationsRequest $request, DesfileEntryReservationService $service): AnonymousResourceCollection { return EntryReservationResource::collection( $service->reservations($request->user(), $request->validated()), @@ -26,7 +26,7 @@ class EntryReservationController extends Controller public function downloadPdf( ExportEntryReservationsRequest $request, - EntryReservationService $service, + DesfileEntryReservationService $service, EntryReservationPdfService $pdf, ): Response { $user = $request->user(); @@ -40,7 +40,7 @@ class EntryReservationController extends Controller public function downloadExcel( ExportEntryReservationsRequest $request, - EntryReservationService $service, + DesfileEntryReservationService $service, EntryReservationExcelService $excel, ): StreamedResponse { $user = $request->user(); @@ -55,7 +55,7 @@ class EntryReservationController extends Controller public function downloadTicketPdf( IndexEntryReservationsRequest $request, int $reservation, - EntryReservationService $service, + DesfileEntryReservationService $service, TicketPdfService $pdf, ): Response { $user = $request->user(); @@ -66,7 +66,7 @@ class EntryReservationController extends Controller ); } - public function store(StoreEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection + public function store(StoreEntryReservationsRequest $request, DesfileEntryReservationService $service): AnonymousResourceCollection { return EntryReservationResource::collection($service->reserve( $request->user(), $request->validated('idempotency_key'), $request->validated('rows'), @@ -76,7 +76,7 @@ class EntryReservationController extends Controller public function destroy( IndexEntryReservationsRequest $request, int $reservation, - EntryReservationService $service, + DesfileEntryReservationService $service, ): Response { $service->cancel($request->user(), $reservation); diff --git a/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationPolicy.php b/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationPolicy.php new file mode 100644 index 00000000..fcf1a4ba --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationPolicy.php @@ -0,0 +1,31 @@ + $selection) { + $variant = $selection['variant']; + if ($variant === null || $selection['inventory'] === null + || isset($seen[$variant->id]) + || $variant->entryReservations()->lockForUpdate()->get()->isNotEmpty()) { + $errors["rows.{$index}.variant_id"] = 'La entrada ya no está disponible.'; + } + if ($variant !== null) { + $seen[$variant->id] = true; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationService.php b/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationService.php new file mode 100644 index 00000000..efade1e3 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/DesfileEntryReservationService.php @@ -0,0 +1,123 @@ + + */ + public function reservations(User $user, array $filters = []): LengthAwarePaginator + { + $result = $this->reservations->reservations($user, [ + ...$filters, 'catalog_item_id' => $this->entryId($user), + ]); + $result->getCollection()->loadMissing(self::SELECTION_RELATIONS); + + return $result; + } + + /** + * @param array{tipo_pago?: string|null} $filters + * @return Collection + */ + public function reservationsForExport(User $user, array $filters = []): Collection + { + return $this->reservations->reservationsForExport($user, [ + ...$filters, 'catalog_item_id' => $this->entryId($user), + ])->loadMissing(self::SELECTION_RELATIONS); + } + + public function reservationTicket(User $user, int $reservationId): Ticket + { + return $this->reservations->reservationTicket($user, $reservationId, $this->entryId($user)); + } + + public function cancel(User $user, int $reservationId): void + { + $this->reservations->cancel($user, $reservationId, $this->entryId($user)); + } + + /** @param list $rows */ + public function reserve(User $user, string $key, array $rows): Collection + { + $this->authorize($user); + $entry = CatalogItem::query()->where('tenant_code', $user->tenant_codigo) + ->where('slug', 'entrada')->firstOrFail(); + $catalogRows = array_map(fn (array $row): array => [ + ...$row, 'catalog_item_id' => $entry->id, + ], $rows); + + try { + return $this->reservations->reserve($user, $key, $catalogRows, $this->policy) + ->loadMissing(self::SELECTION_RELATIONS); + } catch (ValidationException $exception) { + // Keep the row fields used by the form and describe the selected seat. + $variants = $entry->variants()->whereKey(array_column($rows, 'variant_id')) + ->with(['definitions.itemAttribute.attribute.options'])->get()->keyBy('id'); + $errors = []; + foreach ($exception->errors() as $field => $messages) { + if (preg_match('/^rows\.(\d+)\.variant_id$/', $field, $match)) { + $index = (int) $match[1]; + $label = $this->entryLabel($variants->get($rows[$index]['variant_id'] ?? null), $index); + $messages = array_map(fn (string $message): string => $label.': '.$message, $messages); + } + $errors[$field] = $messages; + } + + throw ValidationException::withMessages($errors); + } + } + + private function authorize(User $user): void + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + } + + private function entryId(User $user): int + { + $this->authorize($user); + + // Historical reservations remain accessible when the active event changes. + // A missing entry must not remove the catalog filter from the generic query. + return CatalogItem::query()->where('tenant_code', $user->tenant_codigo) + ->where('slug', 'entrada')->value('id') ?? -1; + } + + private function entryLabel(?Variant $variant, int $index): string + { + if ($variant === null) { + return 'Entrada '.($index + 1); + } + + $values = $variant->selectionValues(); + + return collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) + ->map(fn (string $label, string $key): string => $label.': '.($values->get($key) ?? 'sin especificar')) + ->implode(', '); + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php deleted file mode 100644 index 2f2bd734..00000000 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php +++ /dev/null @@ -1,241 +0,0 @@ - - */ - public function reservations(User $user, array $filters = []): LengthAwarePaginator - { - abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); - - return $this->reservationsQuery($user, $filters) - ->paginate( - perPage: $filters['per_page'] ?? 15, - pageName: 'page', - page: $filters['page'] ?? 1, - ) - ->withQueryString(); - } - - /** - * @param array{tipo_pago?: string|null} $filters - * @return Collection - */ - public function reservationsForExport(User $user, array $filters = []): Collection - { - abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); - - return $this->reservationsQuery($user, $filters)->get(); - } - - public function reservationTicket(User $user, int $reservationId): Ticket - { - abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); - - $reservation = EntryReservation::query() - ->whereKey($reservationId) - ->whereNotNull('ticket_id') - ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query - ->where('tenant_code', $user->tenant_codigo) - ->where('slug', 'entrada')) - ->with(['ticket' => fn ($query) => $query->with([ - ...TicketValidityResolver::RELATIONS, - ...TicketPresentationResolver::RELATIONS, - ])]) - ->firstOrFail(); - - return $reservation->ticket; - } - - public function cancel(User $user, int $reservationId): void - { - abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); - - DB::transaction(function () use ($user, $reservationId): void { - $reservation = EntryReservation::query() - ->whereKey($reservationId) - ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query - ->where('tenant_code', $user->tenant_codigo) - ->where('slug', 'entrada')) - ->lockForUpdate() - ->firstOrFail(); - - if ($reservation->ticket_id !== null) { - $ticket = Ticket::query()->lockForUpdate()->findOrFail($reservation->ticket_id); - - 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()->lockForUpdate()->findOrFail($reservation->inventory_id); - $inventory->releaseEntry(1); - } - - $reservation->delete(); - }, 3); - } - - /** @param array{tipo_pago?: string|null} $filters */ - private function reservationsQuery(User $user, array $filters = []): Builder - { - return EntryReservation::query() - ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query - ->where('tenant_code', $user->tenant_codigo) - ->where('slug', 'entrada')) - ->when( - $filters['tipo_pago'] ?? null, - fn (Builder $query, string $paymentType): Builder => $query->where('tipo_pago', $paymentType), - ) - ->with([ - 'variant.catalogItem.itemAttributes.attribute.options', - 'variant.definitions.itemAttribute.attribute.options', - 'variant.eventDates', - 'variant.eventDate', - ]) - ->orderByDesc('fecha_reserva') - ->orderByDesc('id'); - } - - /** @param list $rows */ - public function reserve(User $user, string $key, array $rows): Collection - { - abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); - $normalized = collect($rows)->map(fn (array $row): array => [ - 'variant_id' => (int) $row['variant_id'], 'tipo_pago' => $row['tipo_pago'], - ])->sortBy('variant_id')->values()->all(); - $hash = hash('sha256', json_encode($normalized, JSON_THROW_ON_ERROR)); - - return DB::transaction(function () use ($user, $key, $rows, $hash): Collection { - // Serialize retries by the same administrator, including the first insert. - User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); - $batch = ReservationBatch::query() - ->where('user_id', $user->id)->where('idempotency_key', $key)->lockForUpdate()->first(); - if ($batch !== null) { - abort_unless($batch->tenant_code === $user->tenant_codigo && hash_equals($batch->request_hash, $hash), 409, - 'La clave de envío ya fue utilizada con otras entradas.'); - - return EntryReservation::query()->where('batch_id', $batch->id)->with('ticket')->orderBy('id')->get(); - } - - $tenant = $user->tenant()->firstOrFail(); - $entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada') - ->lockForUpdate()->firstOrFail(); - $ids = array_column($rows, 'variant_id'); - $variants = $entry->variants()->whereKey($ids)->orderBy('id')->lockForUpdate()->get(); - $inventories = Inventory::query()->whereKey($variants->pluck('inventory_id')->filter()->unique()) - ->orderBy('id')->lockForUpdate()->get()->keyBy('id'); - $variants->load([ - 'eventDates', 'eventDate', - 'entryReservations' => fn ($query) => $query->lockForUpdate(), - ]); - foreach ($variants as $variant) { - $variant->setRelation('inventory', $inventories->get($variant->inventory_id)); - } - $entry->setRelation('variants', $variants); - $available = $entry->visibleVariants()->keyBy('id'); - $requirements = []; - $errors = []; - foreach ($rows as $index => $row) { - $variant = $available->get($row['variant_id']); - if ($variant === null || $variant->inventory === null) { - $errors["rows.{$index}.variant_id"] = $this->entryLabel($variants->firstWhere('id', $row['variant_id']), $index).': la entrada ya no está disponible.'; - - continue; - } - $requirements[$variant->inventory_id] = ($requirements[$variant->inventory_id] ?? 0) + 1; - } - if ($errors !== []) { - throw ValidationException::withMessages($errors); - } - $tracked = $entry->inventory_policy !== InventoryPolicy::Unlimited; - foreach ($requirements as $inventoryId => $quantity) { - if ($tracked && $inventories[$inventoryId]->availableStock() < $quantity) { - foreach ($rows as $index => $row) { - $variant = $available[$row['variant_id']]; - if ($variant->inventory_id === $inventoryId) { - $errors["rows.{$index}.variant_id"] = $this->entryLabel($variant, $index).': no hay stock suficiente para reservar las entradas seleccionadas.'; - } - } - } - } - 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 => $quantity) { - $inventories[$inventoryId]->reserveEntry($quantity, $tracked); - } - $reservations = collect(); - foreach ($rows as $index => $row) { - $variant = $available[$row['variant_id']]; - $payment = EntryReservationPaymentType::from($row['tipo_pago']); - try { - $ticket = $this->tickets->generate($entry, $user, 1, $variant->id)->sole(); - } catch (TicketGenerationException $exception) { - throw ValidationException::withMessages([ - "rows.{$index}.variant_id" => $this->entryLabel($variant, $index).': no se pudo emitir el ticket. '.$exception->getMessage(), - ]); - } - $reservation = EntryReservation::query()->create([ - 'batch_id' => $batch->id, 'ticket_id' => $ticket->id, - 'catalog_item_id' => $entry->id, - 'variant_id' => $variant->id, 'inventory_id' => $variant->inventory_id, - 'fecha_reserva' => now(), 'tipo_pago' => $payment, - 'importe' => $payment === EntryReservationPaymentType::Free ? 0 : $variant->getPrice(), - ]); - $reservations->push($reservation->setRelation('ticket', $ticket)); - } - - return $reservations; - }, 3); - } - - private function entryLabel(?Variant $variant, int $index): string - { - if ($variant === null) { - return 'Entrada '.($index + 1); - } - - $values = $variant->selectionValues(); - - return collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) - ->map(fn (string $label, string $key): string => $label.': '.($values->get($key) ?? 'sin especificar')) - ->implode(', '); - } -} diff --git a/app/Domains/Ticketing/Desfile/routes/api.php b/app/Domains/Ticketing/Desfile/routes/api.php index f588ecdf..b4bb51b3 100644 --- a/app/Domains/Ticketing/Desfile/routes/api.php +++ b/app/Domains/Ticketing/Desfile/routes/api.php @@ -1,26 +1,26 @@ middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.index'); -Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf']) +Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [DesfileEntryReservationController::class, 'downloadPdf']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.pdf'); -Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel']) +Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [DesfileEntryReservationController::class, 'downloadExcel']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.excel'); -Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf']) +Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [DesfileEntryReservationController::class, 'downloadTicketPdf']) ->whereNumber('reservation') ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.ticket.pdf'); -Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store']) +Route::post('v1/adminapp/tenant/desfile/entry-reservations', [DesfileEntryReservationController::class, 'store']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.store'); -Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy']) +Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [DesfileEntryReservationController::class, 'destroy']) ->whereNumber('reservation') ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.destroy'); diff --git a/app/Domains/Ticketing/Ticket/Services/EntryReservationPolicy.php b/app/Domains/Ticketing/Ticket/Services/EntryReservationPolicy.php new file mode 100644 index 00000000..2debd5ac --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Services/EntryReservationPolicy.php @@ -0,0 +1,21 @@ + $selections + * + * @throws ValidationException + */ + public function validate(array $selections): void; +} diff --git a/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php b/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php index fdae5190..3b64572f 100644 --- a/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php +++ b/app/Domains/Ticketing/Ticket/Services/EntryReservationService.php @@ -13,8 +13,8 @@ use App\Domains\Ticketing\Ticket\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Models\ReservationBatch; use App\Domains\Ticketing\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -88,7 +88,7 @@ class EntryReservationService * @param list $rows * @return Collection */ - public function reserve(User $user, string $key, array $rows): Collection + public function reserve(User $user, string $key, array $rows, ?EntryReservationPolicy $policy = null): Collection { $validated = Validator::make(['idempotency_key' => $key, 'rows' => $rows], [ 'idempotency_key' => ['required', 'uuid'], @@ -105,7 +105,7 @@ class EntryReservationService ], $validated['rows']); $hash = $this->requestHash($rows); - return DB::transaction(function () use ($user, $key, $rows, $hash): Collection { + return DB::transaction(function () use ($user, $key, $rows, $hash, $policy): Collection { User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); $tenant = $user->tenant()->firstOrFail(); $batch = ReservationBatch::query()->where('user_id', $user->id) @@ -183,6 +183,8 @@ class EntryReservationService throw ValidationException::withMessages($errors); } + $policy?->validate($selections); + foreach ($requirements as $inventoryId => $requirement) { if ($requirement['tracked'] && $inventories[$inventoryId]->availableStock() < $requirement['quantity']) { foreach ($selections as $index => $selection) { @@ -204,7 +206,7 @@ class EntryReservationService $inventories[$inventoryId]->reserveEntry($requirement['quantity'], $requirement['tracked']); } - $reservations = collect(); + $reservations = new Collection; foreach ($selections as $index => $selection) { $item = $selection['item']; $variant = $selection['variant']; diff --git a/tests/Feature/Desfile/EntryReservationServiceTest.php b/tests/Feature/Desfile/EntryReservationServiceTest.php index 14dee02e..866b55ef 100644 --- a/tests/Feature/Desfile/EntryReservationServiceTest.php +++ b/tests/Feature/Desfile/EntryReservationServiceTest.php @@ -9,15 +9,18 @@ use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource; +use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationPolicy; +use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationService; use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService; use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService; use App\Domains\Ticketing\Desfile\Services\EntryReservationReportService; -use App\Domains\Ticketing\Desfile\Services\EntryReservationService; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Domains\Ticketing\Ticket\Services\EntryReservationService as TicketEntryReservationService; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Schema\Blueprint; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -166,7 +169,12 @@ class EntryReservationServiceTest extends TestCase } } - private function service(bool $failSecond = false): EntryReservationService + private function service(bool $failSecond = false): DesfileEntryReservationService + { + return new DesfileEntryReservationService($this->ticketService($failSecond), new DesfileEntryReservationPolicy); + } + + private function ticketService(bool $failSecond = false): TicketEntryReservationService { $generator = Mockery::mock(TicketGeneratorService::class); $generator->shouldReceive('generate')->andReturnUsing(function ($entry, $user, $quantity, $variantId) use ($failSecond) { @@ -183,7 +191,7 @@ class EntryReservationServiceTest extends TestCase return collect([Ticket::query()->findOrFail($id)]); }); - return new EntryReservationService($generator); + return new TicketEntryReservationService($generator); } private function rows(): array @@ -251,6 +259,120 @@ class EntryReservationServiceTest extends TestCase $service->reserve($user, (string) Str::uuid(), $this->rows()); } + public function test_a_seat_cannot_be_reserved_twice_even_when_inventory_has_more_stock(): void + { + DB::table('inventories')->where('id', 1)->update(['real_stock' => 3]); + $service = $this->service(); + $user = User::findOrFail(1); + $row = $this->rows()[0]; + $reservation = $service->reserve($user, (string) Str::uuid(), [$row])->sole(); + + try { + $service->reserve($user, (string) Str::uuid(), [$row]); + $this->fail('Expected a unique seat despite remaining stock'); + } catch (ValidationException $error) { + $this->assertArrayHasKey('rows.0.variant_id', $error->errors()); + $this->assertStringContainsString('Asiento: 17', $error->errors()['rows.0.variant_id'][0]); + } + $this->assertDatabaseCount('tickets', 1); + $this->assertDatabaseCount('reservation_batches', 1); + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + + $service->cancel($user, $reservation->id); + $replacement = $service->reserve($user, (string) Str::uuid(), [$row])->sole(); + $this->assertNotSame($reservation->id, $replacement->id); + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + } + + public function test_duplicate_seats_in_one_batch_are_rejected_without_changing_stock(): void + { + DB::table('inventories')->where('id', 1)->update(['real_stock' => 3]); + $row = $this->rows()[0]; + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [$row, $row]); + $this->fail('Expected duplicate seat validation'); + } catch (ValidationException $error) { + $this->assertArrayHasKey('rows.1.variant_id', $error->errors()); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('reservation_batches', 0); + $this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock); + } + + public function test_legacy_batches_replay_after_cancellation_and_an_active_event_change(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $result = $service->reserve($user, $key, $this->rows()); + DB::table('reservation_batches')->update([ + 'request_hash' => hash('sha256', json_encode($this->rows(), JSON_THROW_ON_ERROR)), + ]); + $service->cancel($user, $result->first()->id); + DB::table('tenants')->update(['active_event_id' => 20]); + + $replay = $service->reserve($user, $key, array_reverse($this->rows())); + + $this->assertSame($result->last()->id, $replay->sole()->id); + $this->assertSame($result->last()->id, $service->reservations($user)->sole()->id); + $this->assertSame($result->last()->ticket_id, $service->reservationTicket($user, $result->last()->id)->id); + $this->assertSame('NORMAL', $replay->sole()->variant->selectionValues()->get('tipo')); + $this->assertDatabaseCount('tickets', 2); + $this->assertDatabaseCount('reservation_batches', 1); + } + + public function test_desfile_cannot_list_export_download_or_cancel_other_catalog_item_reservations(): void + { + DB::table('catalog_items')->insert([ + 'id' => 2, 'tenant_code' => 'desfile_pura_tendencia', 'slug' => 'other-ticket', + 'inventory_policy' => 'tracked', 'precio' => 100, + ]); + DB::table('inventories')->insert(['id' => 3, 'real_stock' => 1]); + DB::table('variantes')->insert(['id' => 3, 'catalog_item_id' => 2, 'inventory_id' => 3, 'precio' => 100]); + $user = User::findOrFail(1); + $other = $this->ticketService()->reserve($user, (string) Str::uuid(), [ + ['catalog_item_id' => 2, 'variant_id' => 3, 'tipo_pago' => 'sin_cargo'], + ])->sole(); + $service = $this->service(); + $own = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); + + $this->assertSame([$own->id], $service->reservations($user)->pluck('id')->all()); + $this->assertSame([$own->id], $service->reservationsForExport($user)->pluck('id')->all()); + foreach (['reservationTicket', 'cancel'] as $method) { + try { + $service->$method($user, $other->id); + $this->fail('Expected the Desfile catalog scope'); + } catch (ModelNotFoundException) { + $this->assertNull(Ticket::findOrFail($other->ticket_id)->cancelled_at); + } + } + + DB::table('catalog_items')->where('id', 1)->update(['deleted_at' => now()]); + $this->assertSame(0, $service->reservations($user)->total()); + $this->assertCount(0, $service->reservationsForExport($user)); + } + + public function test_desfile_service_rejects_other_tenants_for_every_operation(): void + { + $user = new User(['tenant_codigo' => 'other']); + $service = $this->service(); + $operations = [ + fn () => $service->reservations($user), + fn () => $service->reservationsForExport($user), + fn () => $service->reservationTicket($user, 1), + fn () => $service->cancel($user, 1), + fn () => $service->reserve($user, (string) Str::uuid(), $this->rows()), + ]; + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('Expected the Desfile tenant restriction'); + } catch (HttpException $error) { + $this->assertSame(403, $error->getStatusCode()); + } + } + } + public function test_it_cancels_the_ticket_releases_stock_and_soft_deletes_the_reservation(): void { $service = $this->service(); @@ -441,7 +563,10 @@ class EntryReservationServiceTest extends TestCase $validity->shouldReceive('resolveVariant')->andReturn( ResolvedTicketValidity::unrestricted(), ); - $service = new EntryReservationService(new TicketGeneratorService($validity)); + $service = new DesfileEntryReservationService( + new TicketEntryReservationService(new TicketGeneratorService($validity)), + new DesfileEntryReservationPolicy, + ); $result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); foreach ($result as $reservation) { $this->assertSame(1, $reservation->catalog_item_id); diff --git a/tests/Feature/Ticket/EntryReservationServiceTest.php b/tests/Feature/Ticket/EntryReservationServiceTest.php index e62f6109..7f73c3d2 100644 --- a/tests/Feature/Ticket/EntryReservationServiceTest.php +++ b/tests/Feature/Ticket/EntryReservationServiceTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Ticket; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Core\Auth\Models\User; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Domains\Ticketing\Ticket\Services\EntryReservationPolicy; use App\Domains\Ticketing\Ticket\Services\EntryReservationService; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService; @@ -224,6 +225,45 @@ class EntryReservationServiceTest extends TestCase $service->reserve($user, (string) Str::uuid(), [$row]); } + public function test_custom_policy_validates_resolved_selections_inside_the_transaction_before_stock_changes(): void + { + $policy = Mockery::mock(EntryReservationPolicy::class); + $policy->shouldReceive('validate')->once()->andReturnUsing(function (array $selections): void { + $this->assertGreaterThan(0, DB::transactionLevel()); + $this->assertSame(1, $selections[0]['item']->id); + $this->assertSame(1, $selections[0]['variant']->id); + $this->assertSame(2, $selections[1]['item']->id); + $this->assertNull($selections[1]['variant']); + $this->assertSame(0, $selections[0]['inventory']->entry_reserved_stock); + throw ValidationException::withMessages(['rows.0.variant_id' => 'Custom selection rule.']); + }); + + try { + $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows(), $policy); + $this->fail('Expected custom selection validation'); + } catch (ValidationException $error) { + $this->assertSame(['rows.0.variant_id' => ['Custom selection rule.']], $error->errors()); + } + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('reservation_batches', 0); + $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); + } + + public function test_idempotent_replays_do_not_validate_the_custom_policy_again(): void + { + $policy = Mockery::mock(EntryReservationPolicy::class); + $policy->shouldReceive('validate')->once(); + $service = $this->service(); + $user = User::findOrFail(1); + $key = (string) Str::uuid(); + $result = $service->reserve($user, $key, $this->rows(), $policy); + $replay = $service->reserve($user, $key, array_reverse($this->rows()), $policy); + + $this->assertSame($result->pluck('id')->all(), $replay->pluck('id')->all()); + $this->assertDatabaseCount('tickets', 2); + $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); + } + public function test_shared_inventory_is_checked_for_the_entire_batch(): void { DB::table('catalog_items')->where('id', 2)->update(['inventory_id' => 1]);