*/ 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, ?EntryReservationPolicy $policy = null): 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, $policy): 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.'); // A scope change must not expose any part of a previously submitted batch. if ($user->event_id !== null) { abort_if($batch->reservations()->withTrashed() ->whereDoesntHave('catalogItem', fn (Builder $query) => $query ->where('tenant_code', $user->tenant_codigo) ->where('event_id', $user->event_id)) ->exists(), 403); } return $batch->reservations()->with([...self::SELECTION_RELATIONS, 'ticket'])->orderBy('id')->get(); } $eventId = $user->event_id ?? $tenant->active_event_id; $items = CatalogItem::query()->where('tenant_code', $tenant->codigo) ->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId)) ->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); } $policy?->validate($selections); 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 = new Collection; 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->loadMissing(self::SELECTION_RELATIONS); }, 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($user->event_id !== null, fn (Builder $query) => $query->where('event_id', $user->event_id))) ->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(self::SELECTION_RELATIONS) ->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); } }