/** * Cliente oficial de la API de Chasis (v1). Un solo archivo, sin dependencias. * Node 18+ / Bun / Deno / edge runtimes (usa fetch y Web Crypto). * * SOLO DEL LADO DEL SERVIDOR: la API key nunca puede llegar al navegador. * * import { Chasis } from "./chasis"; * const chasis = new Chasis({ apiKey: process.env.CHASIS_API_KEY! }); * const informe = await chasis.crearInforme({ patente: "AB123CD" }); * * Documentación: https://www.chasis.com.ar/docs (texto plano: https://www.chasis.com.ar/docs/api.md) * Los webhooks y su secret se configuran desde el panel: https://www.chasis.com.ar/cuenta/ajustes */ // ─── Tipos ─────────────────────────────────────────────────────────────────── /** dominio = DNRPA + multas; chasis = sólo multas. "completo" = dominio (nombre anterior). */ export type TipoInforme = "completo" | "chasis" | "dominio"; export type Status = "pending" | "processing" | "completed" | "failed"; export type Fase = "multas" | "dominio"; export interface ErrorInfo { code: string; message: string; } export interface FaseEstado { status: Status; completedAt: string | null; error: ErrorInfo | null; /** Sólo en la fase dominio: el PDF original de DNRPA ya está disponible. */ pdfDisponible?: boolean; /** Sólo en la fase multas ya entregada: puede actualizarse (evento `multas.updated`). */ seguimiento?: boolean; /** Sólo en la fase multas: última actualización posterior a la entrega. */ actualizadoEn?: string | null; } /** Item del listado (sin resultado). */ export interface InformeResumen { id: string; object: "informe"; accountId: string | null; tipo: TipoInforme; patente: string; status: Status; fases: Partial>; /** api = pedido por API, panel = desde el panel, chasis = cargado por el equipo de Chasis. */ source: "api" | "panel" | "chasis"; /** Lo pidió una actualización de la flota (si no, null). */ flota?: { ejecucionId: string } | null; reference: string | null; vehiculo: { marca: string | null; modelo: string | null; anio: string | null } | null; createdAt: string; updatedAt: string; completedAt: string | null; error: ErrorInfo | null; /** `historial`: todos los informes de la patente (GET /vehiculos/{patente}). */ links: { self: string; pdf: string | null; prettyPdf: string | null; dominioPdf: string | null; historial?: string }; } export interface Multa { entity?: { name?: string | null; province?: string | null } | null; infracted_at?: string | null; emitted_at?: string | null; unique_id?: string | number | null; description?: string | null; authority?: string | null; location?: string | null; place?: string | null; address?: string | null; amount?: number | string | null; expires_at?: string | null; requires_controller?: boolean; resolved_at?: string | null; state?: string | null; code?: string | null; infraction_code?: string | null; article?: string | null; } export interface Jurisdiccion { id: string; nombre: string; provincia: string | null; } /** Resultado listo para mostrar. Cada bloque es null hasta que termina su fase (o si la fuente no aplica). */ export interface Resumen { patente: string; vehiculo: { marca: string | null; modelo: string | null; anio: string | null; version?: string | null } | null; multas: { /** Multas abiertas (sin pagar/resolver). */ cantidad: number; montoTotal: number; lista: Multa[]; conMultas: Jurisdiccion[]; } | null; patentes: { tieneDeuda: boolean; montoTotal: number; detalle: { jurisdiccion: string; periodos: string[]; monto: number }[]; consultadas: string[]; } | null; vtv: { vigente: boolean | null; fuente: string } | null; gnc: { tiene: boolean | null } | null; recalls: { tiene: boolean; cantidad: number } | null; dominio: { marca: string | null; modelo: string | null; anio: string | null; nroChasis: string | null; nroMotor: string | null; titularesCantidad: number; /** documento enmascarado (sólo últimos 3 dígitos). */ titulares: { nombre: string; porcentaje: string | null; documento: string | null }[]; /** Prenda, embargo, inhibición, denuncia de robo, etc. que figuran como "SI POSEE". */ estadosActivos: { key: string; label: string }[]; fechaInscripcion: string | null; radicacion: string | null; } | null; } /** Detalle (GET /informes/{id} y payload de los webhooks). */ export interface Informe extends InformeResumen { /** Cada bloque aparece cuando terminó su fase; mientras tanto es null. */ resumen: Resumen | null; } export interface Lista { object: "list"; data: T[]; hasMore: boolean; nextCursor: string | null; } export interface Cupo { /** Cupo del mes. null en cuentas de créditos (no hay cupo mensual). */ quota: number | null; /** Usados en el mes. */ used: number; /** Lo que queda: del cupo del mes, o el saldo de créditos (no vence). */ remaining: number; } export interface Uso { object: "usage"; /** "mensual": cupo que se renueva el 1° de cada mes. "creditos": saldo prepago que no vence. */ billing: "mensual" | "creditos"; /** Mes calendario, hora Argentina. "2026-10" */ period: string; chasis: Cupo; dominio: Cupo; /** Consultas de patente (POST /consultas). */ consulta: Cupo; } export interface Cuenta { object: "account"; accountId: string | null; name: string; email: string | null; status: "active" | "suspended"; billing: "mensual" | "creditos"; plan: { id: string; name: string } | null; usage: { period: string; chasis: Cupo; dominio: Cupo; consulta: Cupo }; notifications: { multas: boolean; dominio: boolean; emails: string[] }; webhooks: { multas: string | null; dominio: string | null; secretConfigured: boolean }; } /** Datos del vehículo y radicación de una patente según DNRPA (POST /consultas). */ export interface Consulta { object: "consulta"; patente: string; /** Dominio tal como figura en DNRPA. */ dominio: string; marca: string | null; /** Modelo con la versión. */ modelo: string | null; anio: number | null; tipo: "automotor" | "moto" | null; placaMercosur: boolean | null; /** Registro seccional donde está radicado el vehículo. */ registro: { codigo: number | null; nombre: string | null; direccion: string | null; localidad: string | null; provincia: string | null }; consultadoEn: string; /** Hasta cuándo una consulta repetida devuelve estos datos sin descontar (15 días). */ vigenteHasta: string; /** Sólo en POST: si esta llamada descontó una consulta. */ cobrada?: boolean; /** Sólo en POST: si se devolvió lo guardado (consultada en los últimos 15 días). */ reutilizada?: boolean; /** Sólo en GET /consultas: la última vez que se pidió. */ ultimaVez?: string; } export interface CrearInformeInput { patente: string; /** default "completo" (equivale a "dominio") */ tipo?: TipoInforme; provincia?: string; /** Tu id interno (≤128, [A-Za-z0-9_.:-]). */ reference?: string; /** * Si reintentás con la misma key no se duplica ni se cobra dos veces (válida 24 h). * Si no la pasás, el cliente genera una por llamada (cubre sus propios reintentos). * Para cubrir reintentos de TU app (doble click, reenvío del form), generala una vez * por pedido del usuario y reusala. */ idempotencyKey?: string; } export interface ListarInput { patente?: string; /** Últimos N días. */ dias?: number; tipo?: TipoInforme; status?: Status; /** 1-100, default 25. */ limit?: number; cursor?: string; } // Flotas export type Periodicidad = "semanal" | "quincenal" | "mensual" | "bimestral" | "trimestral"; export interface Ejecucion { object: "ejecucion"; id: string; /** programada: la del día que toca. manual: actualizarFlota(). alta: informeInicial al agregar autos. */ tipo: "programada" | "manual" | "alta"; /** YYYY-MM-DD, hora Argentina. */ fecha: string; /** en_curso: pidiendo informes. esperando: esperando las multas. terminada: se mandó el email. */ estado: "en_curso" | "esperando" | "terminada"; total: number; pedidos: number; pendientes: number; omitidos: { patente: string; motivo: "sin_cupo" | "error"; detalle: string | null }[]; informes: { patente: string; informeId: string | null; enCurso: boolean }[]; resumen: { actualizados: number; conMultas: number; multas: number; montoMultas: number; conNovedades: number; sinCupo: number; errores: number } | null; createdAt: string; terminadaEn: string | null; } export interface Flota { object: "flota"; activa: boolean; periodicidad: Periodicidad; /** semanal: 1-7 (1 = lunes). quincenal: 1-13 (ese día y 15 días después). Resto: día del mes 1-28. */ dia: number; /** Próximo día que se actualiza (YYYY-MM-DD) o null si está pausada. */ proximaEjecucion: string | null; vehiculos: number; maxVehiculos: number; ultimaEjecucion: Ejecucion | null; } export interface VehiculoFlota { object: "vehiculo_flota"; patente: string; alias: string | null; reference: string | null; provincia: string | null; /** false: se quitó de la flota (el historial sigue disponible). */ activo: boolean; agregadoEn: string; quitadoEn: string | null; ultimaActualizacion: string | null; vehiculo: { marca: string | null; modelo: string | null; anio: string | null } | null; ultimoInforme: { id: string; status: Status; createdAt: string; completedAt: string | null; multas: { cantidad: number; montoTotal: number } | null; patentes: { tieneDeuda: boolean; montoTotal: number } | null; vtv: Resumen["vtv"] | null; links: { self: string }; } | null; links: { self: string; historial: string }; } export interface CambiosInforme { hay: boolean; multasNuevas: { jurisdiccion: string; fecha: string | null; monto: number; descripcion: string | null }[]; /** Multas del informe anterior que ya no figuran (pagadas o prescriptas). */ multasQueNoFiguran: number; /** Cada novedad en una línea legible. */ lineas: string[]; } /** Historial de una patente (GET /vehiculos/{patente} y GET /flota/vehiculos/{patente}). */ export interface HistorialVehiculo { object: "vehiculo"; patente: string; vehiculo: { marca: string | null; modelo: string | null; anio: string | null } | null; /** null si nunca estuvo en la flota. */ flota: { activo: boolean; alias: string | null; reference: string | null; provincia: string | null; agregadoEn: string; quitadoEn: string | null; ultimaActualizacion: string | null } | null; ultimoInforme: { id: string; status: Status; createdAt: string; links: InformeResumen["links"] } | null; /** Del más nuevo al más viejo; `cambios` = qué cambió respecto del anterior (null si no hay con qué comparar). */ informes: Lista; } export interface AutoFlotaInput { patente: string; alias?: string | null; reference?: string | null; provincia?: string | null; } export type WebhookEvento = "multas.completed" | "multas.updated" | "multas.failed" | "dominio.completed" | "dominio.failed" | "webhook.test"; export interface WebhookPayload { id: string; object: "event"; type: WebhookEvento; createdAt: string; data: { informe?: Informe; message?: string }; } // ─── Errores ───────────────────────────────────────────────────────────────── export class ChasisError extends Error { constructor( public status: number, public code: string, message: string, public requestId: string | null, /** Segundos a esperar (429 / 503). */ public retryAfter: number | null, public details?: unknown, ) { super(message); this.name = "ChasisError"; } } // ─── Cliente ───────────────────────────────────────────────────────────────── export interface ChasisOptions { apiKey: string; /** default https://www.chasis.com.ar/api/v1 */ baseUrl?: string; /** Opcional: si lo pasás, la API verifica que la key sea de esta cuenta. */ accountId?: string; /** Reintentos automáticos ante 429/503/red (respeta Retry-After). default 2 */ maxRetries?: number; timeoutMs?: number; } export class Chasis { private base: string; constructor(private opts: ChasisOptions) { if (!opts.apiKey?.startsWith("chs_")) throw new Error("Chasis: apiKey inválida (empieza con chs_live_)"); this.base = (opts.baseUrl ?? "https://www.chasis.com.ar/api/v1").replace(/\/+$/, ""); } // Cuenta y créditos /** Cuenta: plan, cupos del mes, avisos y webhooks. */ cuenta(): Promise { return this.json("GET", "/account"); } /** Créditos del mes: cupo, usados y restantes por tipo. */ uso(): Promise { return this.json("GET", "/usage"); } // Consultas /** * Datos del vehículo y radicación de la patente según DNRPA, en el momento. Descuenta una * consulta; repetida dentro de los 15 días devuelve lo guardado sin descontar. */ consultar(patente: string): Promise { return this.json("POST", "/consultas", { patente }); } /** Consultas de la cuenta, de la más reciente a la más vieja. Con `patente`, sólo esa. */ listarConsultas(f: { limit?: number; cursor?: string; patente?: string } = {}): Promise> { return this.json("GET", `/consultas${qs(f)}`); } // Informes /** Pide un informe. Devuelve 202 con status "processing". */ crearInforme(input: CrearInformeInput): Promise { const { idempotencyKey, ...body } = input; // Siempre con Idempotency-Key: si un reintento sale después de un pedido que sí // llegó (se perdió la respuesta), no se crea ni se cobra otro informe. return this.json("POST", "/informes", body, { "Idempotency-Key": idempotencyKey ?? crypto.randomUUID() }); } /** Listado (sin resultado), del más nuevo al más viejo. */ listarInformes(f: ListarInput = {}): Promise> { const q = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v)); const s = q.toString(); return this.json("GET", `/informes${s ? `?${s}` : ""}`); } /** Recorre todas las páginas del listado. */ async *todosLosInformes(f: Omit = {}): AsyncGenerator { let cursor: string | undefined; do { const page = await this.listarInformes({ ...f, limit: f.limit ?? 100, cursor }); for (const i of page.data) yield i; cursor = page.nextCursor ?? undefined; } while (cursor); } /** Detalle con `resumen`. Máx. 1 consulta cada 10 s por informe. */ informe(id: string): Promise { return this.json("GET", `/informes/${encodeURIComponent(id)}`); } /** PDF del reporte completo de Chasis. 409 not_ready si todavía no terminó ninguna fase. */ pdfInforme(id: string): Promise { return this.binary(`/informes/${encodeURIComponent(id)}/pdf`); } /** PDF con el diseño de Chasis (imagen del auto y todas las secciones), para mostrar al usuario final. 409 not_ready igual que pdfInforme. */ pdfLindo(id: string): Promise { return this.binary(`/informes/${encodeURIComponent(id)}/pretty-pdf`); } /** PDF original de DNRPA. 409 not_ready si todavía no llegó. */ pdfDominio(id: string): Promise { return this.binary(`/informes/${encodeURIComponent(id)}/dominio.pdf`); } /** * Espera a que una fase (o el informe entero) termine, consultando el detalle. * Sirve para esperas cortas: las multas se entregan entre los 15 minutos y la hora, y el * dominio tarda horas, así que en producción usá webhooks o el listado periódico. */ async esperar(id: string, { fase, timeoutMs = 20 * 60_000, intervaloMs = 15_000 }: { fase?: Fase; timeoutMs?: number; intervaloMs?: number } = {}): Promise { const hasta = Date.now() + timeoutMs; for (;;) { try { const i = await this.informe(id); const st = fase ? i.fases[fase]?.status : i.status; if (st === "completed" || st === "failed") return i; } catch (e) { // Fuera de la ventana de polling: seguir mirando el estado desde el listado. if (!(e instanceof ChasisError && e.code === "polling_window_exceeded")) throw e; const l = await this.listarInformes({ limit: 100 }); const item = l.data.find((x) => x.id === id); const st = fase ? item?.fases[fase]?.status : item?.status; if (st === "completed" || st === "failed") return this.informe(id); } if (Date.now() > hasta) throw new ChasisError(408, "timeout", "El informe no terminó en el tiempo de espera", null, null); await sleep(intervaloMs); } } // Flota e historial /** Historial de una patente en la cuenta: todos sus informes, con los cambios entre uno y otro. */ historial(patente: string, f: { limit?: number; cursor?: string } = {}): Promise { return this.json("GET", `/vehiculos/${encodeURIComponent(patente)}${qs(f)}`); } /** Configuración de la flota: periodicidad, próxima actualización y la última. */ flota(): Promise { return this.json("GET", "/flota"); } /** Cambia la periodicidad / el día, o pausa (`activa: false`) y reanuda. */ configurarFlota(c: { periodicidad?: Periodicidad; dia?: number; activa?: boolean }): Promise { return this.json("PATCH", "/flota", c); } /** Autos de la flota con lo esencial de su último informe. */ listarFlota(f: { limit?: number; cursor?: string; q?: string; incluirQuitados?: boolean } = {}): Promise & { total: number }> { return this.json("GET", `/flota/vehiculos${qs(f)}`); } /** Recorre todos los autos de la flota. */ async *todaLaFlota(f: { q?: string; incluirQuitados?: boolean } = {}): AsyncGenerator { let cursor: string | undefined; do { const page = await this.listarFlota({ ...f, limit: 500, cursor }); for (const v of page.data) yield v; cursor = page.nextCursor ?? undefined; } while (cursor); } /** Agrega autos (hasta 500 por llamada). Con `informeInicial` pide ya su primer informe (1 crédito chasis c/u). */ agregarAFlota( vehiculos: (string | AutoFlotaInput)[], { informeInicial = false }: { informeInicial?: boolean } = {}, ): Promise<{ object: "flota_alta"; agregados: string[]; reactivados: string[]; yaEstaban: string[]; ejecucion: Ejecucion | null; flota: Flota }> { return this.json("POST", "/flota/vehiculos", { vehiculos, informeInicial }); } /** Un auto de la flota con su historial (también los que se quitaron). */ vehiculoDeFlota(patente: string, f: { limit?: number; cursor?: string } = {}): Promise { return this.json("GET", `/flota/vehiculos/${encodeURIComponent(patente)}${qs(f)}`); } /** Cambia alias, reference o provincia de un auto. */ editarVehiculoDeFlota(patente: string, datos: Omit): Promise { return this.json("PATCH", `/flota/vehiculos/${encodeURIComponent(patente)}`, datos); } /** Lo quita de la flota: no se actualiza más, pero su historial se conserva. */ quitarDeFlota(patente: string): Promise<{ object: "vehiculo_flota"; patente: string; activo: false; quitadoEn: string }> { return this.json("DELETE", `/flota/vehiculos/${encodeURIComponent(patente)}`); } /** Actualizaciones de la flota, la más reciente primero. */ ejecucionesDeFlota(limit = 25): Promise> { return this.json("GET", `/flota/ejecuciones?limit=${limit}`); } ejecucionDeFlota(id: string): Promise { return this.json("GET", `/flota/ejecuciones/${encodeURIComponent(id)}`); } /** Actualiza ya toda la flota (1 crédito chasis por auto). 409 duplicate_in_progress si hay otra en curso. */ actualizarFlota(): Promise { return this.json("POST", "/flota/ejecuciones"); } // Avisos y webhooks /** Prende/apaga los emails de aviso. */ configurarAvisos(n: Partial>): Promise { return this.json("PATCH", "/account", { notifications: n }); } /** Manda un evento webhook.test a tu URL. */ probarWebhook(): Promise<{ deliveryId: string; status: string | null; lastStatusCode: number | null; lastError: string | null }> { return this.json("POST", "/account/webhook-test"); } // HTTP private headers(extra: Record = {}): Record { return { Authorization: `Bearer ${this.opts.apiKey}`, ...(this.opts.accountId ? { "Chasis-Account": this.opts.accountId } : {}), ...extra, }; } private async request(method: string, path: string, body?: unknown, extra: Record = {}): Promise { const max = this.opts.maxRetries ?? 2; for (let intento = 0; ; intento++) { let res: Response; try { res = await fetch(`${this.base}${path}`, { method, headers: this.headers(body !== undefined ? { "Content-Type": "application/json", ...extra } : extra), body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(this.opts.timeoutMs ?? 60_000), }); } catch (e) { if (intento < max) { await sleep(1000 * 2 ** intento); continue; } throw new ChasisError(0, "network_error", e instanceof Error ? e.message : "Error de red", null, null); } if (res.ok) return res; const retryAfter = Number(res.headers.get("retry-after")) || null; const err = ((await res.json().catch(() => null)) as { error?: { code?: string; message?: string; requestId?: string; details?: unknown } } | null)?.error; const code = err?.code ?? `http_${res.status}`; // Se reintenta sólo lo que tiene sentido reintentar (nunca 402/400/404). const reintentable = res.status === 503 || code === "rate_limited" || code === "poll_too_fast"; if (reintentable && intento < max && (retryAfter ?? 1) <= 60) { await sleep((retryAfter ?? 2 ** intento) * 1000); continue; } throw new ChasisError(res.status, code, err?.message ?? `HTTP ${res.status}`, err?.requestId ?? res.headers.get("x-request-id"), retryAfter, err?.details); } } private async json(method: string, path: string, body?: unknown, extra?: Record): Promise { return (await (await this.request(method, path, body, extra)).json()) as T; } private async binary(path: string): Promise { return (await this.request("GET", path)).arrayBuffer(); } } /** Query string sin vacíos. */ function qs(f: Record) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v)); const s = q.toString(); return s ? `?${s}` : ""; } // ─── Webhooks ──────────────────────────────────────────────────────────────── /** * Verifica la firma de un webhook. `rawBody` = el body CRUDO tal como llegó (string), * sin parsear ni re-serializar. Devuelve el payload parseado o null si la firma no vale. * * El header puede traer varias firmas (`t=..,v1=..,v1=..`): durante las 24 h después de * rotar el secret firmamos con el nuevo y con el anterior. Vale si coincide alguna. */ export async function verificarWebhook(rawBody: string, signatureHeader: string | null, secret: string, toleranciaSeg = 300): Promise { if (!signatureHeader) return null; let t = NaN; const firmas: string[] = []; for (const p of signatureHeader.split(",")) { const i = p.indexOf("="); const k = p.slice(0, i).trim(); const v = p.slice(i + 1).trim(); if (k === "t") t = Number(v); else if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) firmas.push(v); } if (!Number.isInteger(t) || firmas.length === 0) return null; if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return null; const enc = new TextEncoder(); const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${rawBody}`))); // Comparación en tiempo constante contra cada firma. let ok = false; for (const v1 of firmas) { const got = v1.match(/../g)!.map((h) => parseInt(h, 16)); let diff = mac.length ^ got.length; for (let i = 0; i < mac.length; i++) diff |= mac[i] ^ (got[i] ?? 0); if (diff === 0) ok = true; } return ok ? (JSON.parse(rawBody) as WebhookPayload) : null; } function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); }