/** * Validación técnica de la API REAL de BIND ERP (producción, cuenta Balam). * Actividad "Validación técnica de la API de BIND" — Etapa 0. * * REGLAS DURAS (no negociables): * - SOLO LECTURA. Este archivo únicamente construye peticiones GET; no existe * código capaz de emitir POST/PUT/PATCH/DELETE. * - El token se lee de `.env` (BIND_API_TOKEN) y JAMÁS se imprime, se loguea * ni se escribe en el reporte. El serializado final pasa por un scrub que * además redacta cualquier patrón tipo RFC o email por si el sanitizador * estructural dejara pasar algo. * - El reporte solo conserva ESTRUCTURA: nombres de campos, tipos, formatos, * conteos, códigos de estatus. Nunca valores reales (nombres, RFCs, montos, * folios, correos). * - Presupuesto duro de peticiones (default 120 < 150 acordado; el límite de * BIND es 20K/día). Cada retry cuenta contra el presupuesto. * * Salida: validation-output/report.json (sanitizado; el folder está gitignoreado * por defensa en profundidad) + resumen sanitizado en consola. * * Uso: npm run validate:real */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, ".."); const OUT_DIR = join(ROOT, "validation-output"); // ─── Configuración ────────────────────────────────────────────────────────── const env = loadEnv(); const BASE = (env.BIND_BASE_URL ?? process.env.BIND_BASE_URL ?? "https://api.bind.com.mx").replace(/\/+$/, ""); const TOKEN = env.BIND_API_TOKEN ?? env.BIND_API_KEY ?? process.env.BIND_API_TOKEN ?? ""; const BUDGET = Number(env.VALIDATION_BUDGET ?? 120); const PACE_MS = 120; // pausa entre peticiones — gentileza con producción function loadEnv(): Record { const out: Record = {}; try { const raw = readFileSync(join(ROOT, ".env"), "utf8"); for (const line of raw.split(/\r?\n/)) { const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); if (m && m[1] && m[2] !== undefined) out[m[1]] = m[2].replace(/^["']|["']$/g, ""); } } catch { /* sin .env — se valida abajo */ } return out; } // ─── Sonda HTTP (GET-only por construcción) ───────────────────────────────── let used = 0; interface Probe { path: string; status: number; ok: boolean; contentType: string | null; headers: Record; // allowlist no sensible bodyShape: "array" | "odata-value" | "object" | "empty" | "non-json"; rowCount: number | null; count: number | string | null; // odata.count viene como string en OData v3 nextLink: boolean; errorSnippet?: string; /** SOLO en memoria — nunca va al reporte. */ rows: unknown[] | null; } const HEADER_KEEP = /rate|limit|quota|remain|retry-after|dataserviceversion|odata-version|content-type|www-authenticate|apim/i; async function probeGet(path: string, tokenOverride?: string | null): Promise { if (used >= BUDGET) throw new Error(`Presupuesto de ${BUDGET} peticiones agotado — abortando por seguridad.`); const token = tokenOverride === undefined ? TOKEN : tokenOverride; for (let attempt = 0; attempt < 2; attempt++) { used++; const headers: Record = { Accept: "application/json" }; if (token) headers.Authorization = `Bearer ${token}`; let res: Response; try { res = await fetch(`${BASE}${path}`, { method: "GET", // ÚNICO método en todo el archivo headers, signal: AbortSignal.timeout(25_000), }); } catch (err) { await sleep(PACE_MS); if (attempt === 0) continue; return { path, status: 0, ok: false, contentType: null, headers: {}, bodyShape: "empty", rowCount: null, count: null, nextLink: false, errorSnippet: `network: ${scrub(String((err as Error).message)).slice(0, 120)}`, rows: null, }; } const keep: Record = {}; res.headers.forEach((v, k) => { if (HEADER_KEEP.test(k)) keep[k] = v; }); const text = await res.text(); let json: unknown = null; try { json = text ? JSON.parse(text) : null; } catch { /* non-json */ } if ((res.status === 429 || res.status >= 500) && attempt === 0) { const ra = Number(res.headers.get("Retry-After") ?? 2); console.log(` ⏳ ${res.status} en ${path} — retry en ${ra}s`); await sleep(Math.min(ra, 10) * 1000); continue; } const { rows, bodyShape, count, nextLink } = extractRows(json, text); const probe: Probe = { path, status: res.status, ok: res.ok, contentType: res.headers.get("content-type"), headers: keep, bodyShape, rowCount: rows ? rows.length : null, count, nextLink, rows, }; if (!res.ok) { const raw = typeof json === "object" && json !== null ? JSON.stringify(json) : text; probe.errorSnippet = scrub(raw ?? "").slice(0, 300); } await sleep(PACE_MS); return probe; } throw new Error("unreachable"); } function extractRows(json: unknown, text: string): Pick { if (json === null) return { rows: null, bodyShape: text.trim() ? "non-json" : "empty", count: null, nextLink: false }; if (Array.isArray(json)) return { rows: json, bodyShape: "array", count: null, nextLink: false }; if (typeof json === "object") { const o = json as Record; const value = o["value"]; if (Array.isArray(value)) { const count = (o["odata.count"] ?? o["@odata.count"] ?? null) as number | string | null; const nextLink = Boolean(o["odata.nextLink"] ?? o["@odata.nextLink"]); return { rows: value, bodyShape: "odata-value", count, nextLink }; } return { rows: [json], bodyShape: "object", count: null, nextLink: false }; } return { rows: null, bodyShape: "non-json", count: null, nextLink: false }; } // ─── Sanitizador estructural ──────────────────────────────────────────────── interface FieldInfo { name: string; types: string[]; formats: string[]; nullable: boolean; enumValues?: string[]; } /** Campos cuyo VALOR es un código de proceso (no dato personal) y puede documentarse. */ const ENUM_FIELD = /status|estatus|type|tipo|method|metodo|use|uso|currency|moneda|cfdi|way/i; /** Nunca documentar valores de campos que huelan a monto/cantidad aunque matcheen arriba. */ const ENUM_EXCLUDE = /total|amount|monto|price|cost|balance|exchange|sum|qty|quantity|rate|saldo/i; /** Literales de catálogo SAT (PPD/PUE, uso CFDI) — seguros y valiosos; se permite más largo. */ const SAT_CATALOG_FIELD = /cfdi(use|paymentmethod|paymentterm)|paymentmethod|regimenfiscal/i; function formatHint(v: unknown): string { if (v === null || v === undefined) return "null"; if (typeof v === "boolean") return "boolean"; if (typeof v === "number") return Number.isInteger(v) ? "integer" : "decimal"; if (Array.isArray(v)) return "array"; if (typeof v === "object") return "object"; const s = String(v); if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return "guid/uuid"; if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) return "datetime-iso"; if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date-iso"; if (/^\/Date\(-?\d+([+-]\d{4})?\)\/$/.test(s)) return "datetime-wcf(/Date(ms)/)"; if (/^[\w.+-]+@[\w-]+\.[\w.]+$/.test(s)) return "email(REDACTADO)"; if (/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/.test(s)) return "rfc(REDACTADO)"; if (/^https?:\/\//.test(s)) return "url"; if (/^[A-Z]{3}$/.test(s)) return "code-3letras"; return `string(≈${lenBucket(s.length)})`; } function lenBucket(n: number): string { if (n === 0) return "vacío"; if (n <= 10) return "corto"; if (n <= 40) return "medio"; return "largo"; } function safeEnumValue(fieldName: string, v: unknown): string | null { if (ENUM_EXCLUDE.test(fieldName)) return null; if (!ENUM_FIELD.test(fieldName)) return null; // Números: solo enteros pequeños (códigos de catálogo), jamás montos/decimales. if (typeof v === "number") return Number.isInteger(v) && Math.abs(v) < 1000 ? String(v) : null; const maxLen = SAT_CATALOG_FIELD.test(fieldName) ? 60 : 14; if (typeof v !== "string" || v.length === 0 || v.length > maxLen) return null; const h = formatHint(v); if (/REDACTADO|guid|datetime|date-iso|url/.test(h)) return null; return v; } /** Analiza filas y devuelve SOLO estructura. Recorre objetos anidados un nivel (Lines[].Campo). */ function analyzeRows(rows: unknown[], cap = 5): FieldInfo[] { const acc = new Map; formats: Set; nullable: boolean; enums: Set }>(); const visit = (obj: Record, prefix: string) => { for (const [k, v] of Object.entries(obj)) { const name = prefix + k; let rec = acc.get(name); if (!rec) { rec = { types: new Set(), formats: new Set(), nullable: false, enums: new Set() }; acc.set(name, rec); } if (v === null || v === undefined) { rec.nullable = true; rec.types.add("null"); continue; } rec.types.add(Array.isArray(v) ? "array" : typeof v); rec.formats.add(formatHint(v)); const ev = safeEnumValue(k, v); if (ev !== null && rec.enums.size < 10) rec.enums.add(ev); if (!prefix && Array.isArray(v) && typeof v[0] === "object" && v[0] !== null) { visit(v[0] as Record, `${k}[].`); } else if (!prefix && typeof v === "object" && !Array.isArray(v)) { visit(v as Record, `${k}.`); } } }; for (const row of rows.slice(0, cap)) { if (typeof row === "object" && row !== null && !Array.isArray(row)) { visit(row as Record, ""); } } return [...acc.entries()].map(([name, r]) => { const fi: FieldInfo = { name, types: [...r.types], formats: [...r.formats], nullable: r.nullable, }; if (r.enums.size > 0) fi.enumValues = [...r.enums]; return fi; }); } function scrub(s: string): string { let out = s; if (TOKEN) out = out.replaceAll(TOKEN, "[TOKEN-REDACTADO]"); return out .replace(/[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}/g, "[RFC-REDACTADO]") .replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[EMAIL-REDACTADO]"); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } // ─── Reporte ──────────────────────────────────────────────────────────────── interface InventoryEntry { resource: string; path: string; status: number; bodyShape: string; rowCount: number | null; note: string; } const report = { meta: { ranAt: new Date().toISOString(), baseUrl: BASE, tokenSource: ".env BIND_API_TOKEN (usuario BIND: Arturo Rosas, según correo de Pedro 6-jul-2026)", budget: BUDGET, requestsUsed: 0, note: "Reporte sanitizado: solo estructura (campos, tipos, formatos, conteos, códigos). Sin valores reales.", }, auth: {} as Record, inventory: [] as InventoryEntry[], shapes: {} as Record, odata: {} as Record, byId: {} as Record, multiCompany: {} as Record, balanceQuestion: {} as Record, headersObserved: {} as Record, paymentsHunt: {} as Record, statusSemantics: {} as Record, prefactura: {} as Record, pagination: {} as Record, seriesHunt: {} as Record, }; function projection(p: Probe): Omit { const { rows: _rows, ...rest } = p; return rest; } // ─── Fases ────────────────────────────────────────────────────────────────── const PRIORITY_RESOURCES = [ "Invoices", "Clients", "Customers", "Payments", "Quotes", "Quotations", "Cotizaciones", "Products", "Currencies", "Warehouses", "Locations", "Branches", "Sucursales", "Series", ]; const SECONDARY_RESOURCES = [ "Activities", "CreditNotes", "Taxes", "PriceLists", "Prices", "Orders", "SalesOrders", "PurchaseOrders", "Providers", "Suppliers", "Banks", "BankAccounts", "Sellers", "Employees", "Users", "Companies", "Expenses", "Inventory", "CFDI", "CFDIs", ]; async function phaseAuth(): Promise { console.log("\n── Fase 1 · Autenticación"); // Endpoint de referencia barato. Products está documentado públicamente. const ok = await probeGet("/api/Products?$top=1"); const noToken = await probeGet("/api/Products?$top=1", null); const badToken = await probeGet("/api/Products?$top=1", "invalid-token-abc123"); report.auth = { scheme: "Authorization: Bearer (único header; sin Ocp-Apim-Subscription-Key)", validToken: projection(ok), missingToken: projection(noToken), invalidToken: projection(badToken), }; Object.assign(report.headersObserved, ok.headers); console.log(` token válido → ${ok.status} · sin token → ${noToken.status} · token corrupto → ${badToken.status}`); if (!ok.ok) { console.log(" ⚠️ El token de .env NO autenticó contra /api/Products. Revisar antes de seguir."); } return ok.ok ? "ok" : "fail"; } async function phaseInventory(): Promise> { console.log("\n── Fase 2 · Inventario de recursos (GET {recurso}?$top=1)"); const results = new Map(); for (const res of [...PRIORITY_RESOURCES, ...SECONDARY_RESOURCES]) { let p = await probeGet(`/api/${res}?$top=1`); let note = ""; // Algunos endpoints podrían rechazar $top — reintenta plano solo para prioritarios. if (p.status === 400 && PRIORITY_RESOURCES.includes(res)) { const plain = await probeGet(`/api/${res}`); if (plain.ok) { p = plain; note = "existe pero rechaza $top=1 (400)"; } else note = "400 con y sin $top"; } if (p.status === 404) note ||= "no existe con este nombre"; if (p.status === 401 || p.status === 403) note ||= "sin permiso para este token"; if (p.ok) note ||= `responde ${p.bodyShape}`; results.set(res, p); report.inventory.push({ resource: res, path: p.path, status: p.status, bodyShape: p.bodyShape, rowCount: p.rowCount, note, }); Object.assign(report.headersObserved, p.headers); console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${p.status}${note ? ` (${note})` : ""}`); } return results; } async function phaseShapes(inventory: Map): Promise> { console.log("\n── Fase 3 · Inventario de campos ($top=5, solo estructura)"); const rowsByResource = new Map(); const targets = [...PRIORITY_RESOURCES, "Companies", "CreditNotes", "Activities"] .filter((r) => inventory.get(r)?.ok); for (const res of targets) { const p = await probeGet(`/api/${res}?$top=5`); const rows = p.rows ?? []; rowsByResource.set(res, rows); report.shapes[res] = { rowsAnalyzed: Math.min(rows.length, 5), totalCount: p.count, fields: analyzeRows(rows), }; console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${rows.length} filas analizadas, ${report.shapes[res].fields.length} campos`); } return rowsByResource; } async function phaseOData(rowsByResource: Map): Promise { console.log("\n── Fase 4 · Mecánica OData"); // Elige el mejor recurso disponible para las pruebas. const resource = ["Invoices", "Products", "Clients", "Customers"].find((r) => rowsByResource.has(r)); if (!resource) { report.odata = { skipped: "ningún recurso disponible" }; return; } const fields = report.shapes[resource]?.fields ?? []; const idField = fields.find((f) => /^id$/i.test(f.name))?.name ?? fields.find((f) => f.formats.includes("guid/uuid"))?.name; const dateField = fields.find((f) => f.formats.some((x) => x.startsWith("datetime")))?.name; const numField = fields.find((f) => /integer|decimal/.test(f.formats.join()) && !/id/i.test(f.name))?.name; const enumField = fields.find((f) => f.enumValues?.length); const odata: Record = { resourceUsed: resource, idField, dateField, numField }; // $top / $skip coherentes const a = await probeGet(`/api/${resource}?$top=2`); const b = await probeGet(`/api/${resource}?$top=1&$skip=1`); if (idField && a.rows?.length === 2 && b.rows?.length === 1) { const id = (r: unknown) => (r as Record)[idField]; odata.topSkip = { works: id(a.rows[1]) === id(b.rows[0]), statuses: [a.status, b.status] }; } else { odata.topSkip = { works: null, statuses: [a.status, b.status], note: "sin filas suficientes para comparar" }; } // $orderby if (dateField) { const o = await probeGet(`/api/${resource}?$top=3&$orderby=${encodeURIComponent(`${dateField} asc`)}`); let sorted: boolean | null = null; if (o.rows && o.rows.length >= 2) { const vals = o.rows.map((r) => String((r as Record)[dateField] ?? "")); sorted = vals.every((v, i) => i === 0 || v >= String(vals[i - 1])); } odata.orderby = { field: dateField, status: o.status, ascendingVerified: sorted }; } // Conteo total: v3 ($inlinecount) vs v4 ($count) const v3 = await probeGet(`/api/${resource}?$top=1&$inlinecount=allpages`); const v4 = await probeGet(`/api/${resource}?$top=1&$count=true`); odata.countMechanism = { "v3 $inlinecount=allpages": { status: v3.status, countReturned: v3.count !== null, totalCount: v3.count }, "v4 $count=true": { status: v4.status, countReturned: v4.count !== null, totalCount: v4.count }, }; // $filter numérico if (numField) { const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${numField} ge 0`)}`); odata.filterNumeric = { expr: `${numField} ge 0`, status: f.status, rows: f.rowCount }; } // $filter por enum observado (código de proceso, no dato personal) if (enumField?.enumValues?.[0] !== undefined) { const isNum = /^\d+$/.test(enumField.enumValues[0]); const lit = isNum ? enumField.enumValues[0] : `'${enumField.enumValues[0]}'`; const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${enumField.name} eq ${lit}`)}`); odata.filterEnum = { expr: `${enumField.name} eq ${lit}`, status: f.status, rows: f.rowCount }; } // $filter por fecha: sintaxis v3 (datetime'...') vs v4 (literal ISO) if (dateField) { const fv3 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge datetime'2020-01-01T00:00:00'`)}`); let fv4: Probe | null = null; if (!fv3.ok) fv4 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge 2020-01-01T00:00:00Z`)}`); odata.filterDate = { "v3 datetime'...'": fv3.status, ...(fv4 ? { "v4 ISO literal": fv4.status } : {}), verdict: fv3.ok ? "sintaxis OData v3" : fv4?.ok ? "sintaxis OData v4" : "ninguna funcionó", }; } // $select if (idField) { const s = await probeGet(`/api/${resource}?$top=1&$select=${idField}`); odata.select = { status: s.status, works: s.ok }; // Página default (barata: solo IDs) — tamaño de página y nextLink if (s.ok) { const d = await probeGet(`/api/${resource}?$select=${idField}`); odata.defaultPage = { rowsReturned: d.rowCount, nextLinkPresent: d.nextLink, totalCount: d.count, status: d.status }; } } report.odata = odata; console.log(` recurso de prueba: ${resource} · resultados en reporte`); } async function phaseById(rowsByResource: Map): Promise { console.log("\n── Fase 5 · GET por ID"); const resource = ["Invoices", "Clients", "Customers", "Products"].find((r) => rowsByResource.has(r) && (rowsByResource.get(r)?.length ?? 0) > 0); if (!resource) { report.byId = { skipped: "sin filas para tomar un ID" }; return; } const fields = report.shapes[resource]?.fields ?? []; const idField = fields.find((f) => /^id$/i.test(f.name))?.name; if (!idField) { report.byId = { skipped: "sin campo ID identificable" }; return; } const firstId = String((rowsByResource.get(resource)![0] as Record)[idField] ?? ""); if (!firstId) { report.byId = { skipped: "ID vacío" }; return; } const odataStyle = await probeGet(`/api/${resource}(guid'${firstId}')`); let restStyle: Probe | null = null; if (!odataStyle.ok) restStyle = await probeGet(`/api/${resource}/${firstId}`); report.byId = { resource, "odata (guid'...')": odataStyle.status, ...(restStyle ? { "rest (/{id})": restStyle.status } : {}), verdict: odataStyle.ok ? "estilo OData key" : restStyle?.ok ? "estilo REST /{id}" : "ninguno funcionó", // ¿El detalle trae más campos que la lista? (p.ej. Lines embebidas) detailFieldCount: odataStyle.ok || restStyle?.ok ? analyzeRows((odataStyle.ok ? odataStyle : restStyle!).rows ?? []).length : null, listFieldCount: fields.length, }; if (odataStyle.ok || restStyle?.ok) { const detail = (odataStyle.ok ? odataStyle : restStyle!).rows ?? []; report.shapes[`${resource}(detalle por ID)`] = { rowsAnalyzed: detail.length, totalCount: null, fields: analyzeRows(detail), }; } console.log(` ${resource} por ID → OData:${odataStyle.status}${restStyle ? ` / REST:${restStyle.status}` : ""}`); } function phaseBalanceVerdict(): void { const inv = report.shapes["Invoices"] ?? report.shapes["Invoices(detalle por ID)"]; if (!inv) { report.balanceQuestion = { verdict: "SIN VALIDAR — Invoices no accesible" }; return; } const all = [ ...(report.shapes["Invoices"]?.fields ?? []), ...(report.shapes["Invoices(detalle por ID)"]?.fields ?? []), ]; const balanceish = [...new Set(all.filter((f) => /balance|saldo|due|paid|pending|remain|credit|debt|payment/i.test(f.name)).map((f) => f.name))]; report.balanceQuestion = { fieldsMatching: balanceish, verdict: balanceish.length > 0 ? "REVISAR nombres arriba — hay candidatos a saldo por factura (Plan A probable)" : "Sin campo de saldo visible en Invoices (apunta a Plan B: total − pagos)", }; } function phaseMultiCompany(inventory: Map): void { const companies = inventory.get("Companies"); const companyFields: string[] = []; for (const [res, shape] of Object.entries(report.shapes)) { for (const f of shape.fields) { if (/company|empresa/i.test(f.name)) companyFields.push(`${res}.${f.name}`); } } report.multiCompany = { companiesEndpoint: companies ? { status: companies.status, rowCount: companies.rowCount } : "no sondeado", companyLikeFields: companyFields, note: "1 token = 1 usuario BIND (kickoff #22). Si Companies no existe o regresa 1 fila, el token está acotado a la empresa del usuario (Balam).", }; } // ─── Fases de ronda 2 (sondeos dirigidos) ───────────────────────────── async function phasePaymentsHunt(): Promise { console.log("\n── Ronda 2 · Búsqueda del recurso de pagos"); const candidates = [ "Payment", "ClientPayments", "CustomerPayments", "Incomes", "Income", "Deposits", "Collections", "PaymentComplements", "Complements", "CashReceipts", "AccountsReceivable", "Receivables", ]; const found: Record = {}; for (const c of candidates) { const p = await probeGet(`/api/${c}?$top=1`); found[c] = p.status; console.log(` [${String(used).padStart(3)}/${BUDGET}] ${c.padEnd(20)} → ${p.status}`); if (p.ok && p.rows) { report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; } } // Sub-recurso bajo factura: /api/Invoices/{id}/Payments const inv = await probeGet("/api/Invoices?$top=1"); const invId = inv.rows?.[0] ? String((inv.rows[0] as Record)["ID"] ?? "") : ""; const subProbes: Record = {}; if (invId) { for (const sub of ["Payments", "payments", "CreditNotes"]) { const p = await probeGet(`/api/Invoices/${invId}/${sub}`); subProbes[`Invoices/{id}/${sub}`] = p.status; console.log(` [${String(used).padStart(3)}/${BUDGET}] Invoices/{id}/${sub.padEnd(12)} → ${p.status}`); if (p.ok && p.rows?.length) { report.shapes[`Invoices/{id}/${sub}`] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; } } } report.paymentsHunt = { collectionCandidates: found, subResources: subProbes, invoiceEmbeddedField: "Invoices.Payments es numérico (acumulado pagado) — ver shapes", }; } async function phaseStatusSemantics(): Promise { console.log("\n── Ronda 2 · Semántica de estatus"); // Quotes: la lista trae Status(int) + StatusText(string) en la misma fila — mapeo barato. const quoteMap: Record = {}; for (const code of [0, 1, 2, 3, 4, 5]) { const p = await probeGet(`/api/Quotes?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`); if (p.ok && p.rows?.length) { const r = p.rows[0] as Record; quoteMap[String(code)] = String(r["StatusText"] ?? "(sin StatusText)"); } else if (!p.ok) { quoteMap[String(code)] = `error ${p.status}`; } } console.log(` Quotes.Status → ${JSON.stringify(quoteMap)}`); // Invoices: lista trae Status(int); el label vive en el detalle (Status string + StatusCode int). const invoiceMap: Record = {}; for (const code of [0, 1, 2, 3, 4, 5]) { const list = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`); if (!list.ok || !list.rows?.length) continue; const id = String((list.rows[0] as Record)["ID"] ?? ""); if (!id) continue; const det = await probeGet(`/api/Invoices/${id}`); if (det.ok && det.rows?.length) { const d = det.rows[0] as Record; invoiceMap[String(code)] = `${String(d["Status"] ?? "?")} (StatusCode=${String(d["StatusCode"] ?? "?")})`; } } console.log(` Invoices.Status → ${JSON.stringify(invoiceMap)}`); report.statusSemantics = { quotesStatusToText: quoteMap, invoicesStatusToLabel: invoiceMap, note: "Labels vienen del propio API (campo StatusText / Status del detalle); son códigos de proceso, no datos personales.", }; } async function phasePrefactura(): Promise { console.log("\n── Ronda 2 · ¿Prefacturas visibles? (UUID null / IsFiscalInvoice false)"); const uuidNull = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("UUID eq null")}`); const notFiscal = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("IsFiscalInvoice eq false")}`); report.prefactura = { "filter UUID eq null": { status: uuidNull.status, rows: uuidNull.rowCount }, "filter IsFiscalInvoice eq false": { status: notFiscal.status, rows: notFiscal.rowCount }, interpretation: (uuidNull.rowCount ?? 0) > 0 || (notFiscal.rowCount ?? 0) > 0 ? "Hay documentos sin timbrar visibles en /api/Invoices — prefactura distinguible vía UUID/IsFiscalInvoice" : "Con los filtros probados no aparecieron prefacturas — posible que /api/Invoices solo exponga CFDI timbrados (validar en UI con Arturo)", }; console.log(` UUID null → ${uuidNull.status}/${uuidNull.rowCount} filas · IsFiscalInvoice false → ${notFiscal.status}/${notFiscal.rowCount} filas`); } async function phaseDetailShapes(): Promise { console.log("\n── Ronda 2 · Detalle por ID de Clients y Quotes"); for (const res of ["Clients", "Quotes"]) { const list = await probeGet(`/api/${res}?$top=1`); const id = list.rows?.[0] ? String((list.rows[0] as Record)["ID"] ?? "") : ""; if (!id) continue; const det = await probeGet(`/api/${res}/${id}`); console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res}/{id} → ${det.status} (${det.ok ? analyzeRows(det.rows ?? []).length : 0} campos)`); if (det.ok && det.rows?.length) { report.shapes[`${res}(detalle por ID)`] = { rowsAnalyzed: det.rows.length, totalCount: null, fields: analyzeRows(det.rows), }; } } } async function phasePagination(): Promise { console.log("\n── Ronda 2 · Paginación"); const plain = await probeGet("/api/Quotes"); // colección chica conocida; mide page size default const top101 = await probeGet("/api/Quotes?$top=101"); report.pagination = { "GET sin $top (Quotes)": { rows: plain.rowCount, nextLink: plain.nextLink, count: plain.count, status: plain.status }, "GET $top=101 (Quotes)": { rows: top101.rowCount, nextLink: top101.nextLink, status: top101.status }, note: "Si rows < total esperado y no hay nextLink, la paginación es por $top/$skip manual.", }; console.log(` sin $top → ${plain.rowCount} filas (nextLink=${plain.nextLink}) · $top=101 → ${top101.rowCount} filas`); } async function phaseSeriesHunt(): Promise { console.log("\n── Ronda 2 · Series de facturación"); const out: Record = {}; for (const c of ["InvoiceSeries", "Folios", "DocumentSeries"]) { const p = await probeGet(`/api/${c}?$top=1`); out[c] = p.status; if (p.ok && p.rows?.length) { report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; } } report.seriesHunt = out; console.log(` ${JSON.stringify(out)}`); } /** Carga el reporte previo y purga enumValues de campos tipo monto (fuga corregida). */ function loadPreviousReport(): boolean { try { const prev = JSON.parse(readFileSync(join(OUT_DIR, "report.json"), "utf8")) as typeof report; Object.assign(report, prev); for (const shape of Object.values(report.shapes)) { for (const f of shape.fields) { if (f.enumValues && ENUM_EXCLUDE.test(f.name)) delete f.enumValues; } } return true; } catch { return false; } } // ─── Fases de ronda 3 (cabos sueltos) ─────────────────────────────────────── async function phaseCaps(): Promise { console.log("\n── Ronda 3 · Límites de $top y tamaño de colecciones"); const top100 = await probeGet("/api/Quotes?$top=100"); const skip100 = await probeGet("/api/Invoices?$top=1&$skip=100"); const skip1000 = await probeGet("/api/Invoices?$top=1&$skip=1000"); report.pagination = { ...(report.pagination as Record), "GET $top=100 (Quotes)": { status: top100.status, rows: top100.rowCount }, topCapVerdict: top100.ok ? "$top acepta hasta 100; 101 → 500" : `$top=100 también falla (${top100.status})`, invoicesSizeBracket: { "skip=100 devuelve fila": (skip100.rowCount ?? 0) > 0, "skip=1000 devuelve fila": (skip1000.rowCount ?? 0) > 0, note: "brackets aproximados del total de facturas históricas, sin descargar la colección", }, }; console.log(` $top=100 → ${top100.status} · skip100 → ${skip100.rowCount} · skip1000 → ${skip1000.rowCount}`); } async function phaseCfdiLiterals(): Promise { console.log("\n── Ronda 3 · Literales CFDI (PPD/PUE, uso CFDI) — sanitizador corregido"); const list = await probeGet("/api/Invoices?$top=2"); const rows = list.rows ?? []; const detailRows: unknown[] = []; for (const r of rows) { const id = String((r as Record)["ID"] ?? ""); if (!id) continue; const det = await probeGet(`/api/Invoices/${id}`); if (det.ok && det.rows) detailRows.push(...det.rows); } if (detailRows.length) { report.shapes["Invoices(detalle por ID)"] = { rowsAnalyzed: detailRows.length, totalCount: null, fields: analyzeRows(detailRows), }; const f = report.shapes["Invoices(detalle por ID)"].fields.find((x) => x.name === "CFDIPaymentMethod"); console.log(` CFDIPaymentMethod literales → ${JSON.stringify(f?.enumValues ?? [])}`); } } async function phasePaymentsHuntEs(): Promise { console.log("\n── Ronda 3 · Pagos: nombres en español y variantes finales"); const extra: Record = {}; for (const c of ["Cobros", "Pagos", "InvoicePayments", "PaymentsReceived"]) { const p = await probeGet(`/api/${c}?$top=1`); extra[c] = p.status; if (p.ok && p.rows?.length) { report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) }; } } report.paymentsHunt = { ...(report.paymentsHunt as Record), spanishAndFinal: extra }; console.log(` ${JSON.stringify(extra)}`); } async function phaseDocEndpoints(): Promise { console.log("\n── Ronda 3 · ¿Descarga de PDF/XML del CFDI? (solo estatus; el contenido se descarta)"); const list = await probeGet("/api/Invoices?$top=1"); const id = list.rows?.[0] ? String((list.rows[0] as Record)["ID"] ?? "") : ""; const out: Record = {}; if (id) { for (const sub of ["pdf", "xml", "PDF", "cfdi"]) { const p = await probeGet(`/api/Invoices/${id}/${sub}`); out[`Invoices/{id}/${sub}`] = { status: p.status, contentType: p.contentType }; if (p.ok) break; // con uno confirmado basta } } (report as Record)["documentDownload"] = out; console.log(` ${JSON.stringify(out)}`); } // ─── Ronda 4: verificación aritmética del saldo (en memoria, sin persistir montos) ─── async function phaseBalanceArithmetic(): Promise { console.log("\n── Ronda 4 · Verificación del saldo: ¿Payments acumula lo pagado? (aritmética en memoria)"); const paid = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 1")}`); const active = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 0")}`); const near = (a: number, b: number) => Math.abs(a - b) < 0.01; const summarize = (rows: unknown[]) => rows.map((r) => { const o = r as Record; const total = Number(o["Total"] ?? NaN); const pay = Number(o["Payments"] ?? NaN); const cn = Number(o["CreditNotes"] ?? 0); return { settled: near(pay + cn, total), residualPositive: total - pay - cn > 0.01 }; }); const paidChecks = summarize(paid.rows ?? []); const activeChecks = summarize(active.rows ?? []); report.balanceQuestion = { ...(report.balanceQuestion as Record), arithmetic: { paidSample: { n: paidChecks.length, allSettled: paidChecks.every((c) => c.settled) }, activeSample: { n: activeChecks.length, allWithResidual: activeChecks.every((c) => c.residualPositive) }, formula: "SaldoPorFactura = Total − Payments − CreditNotes (campos de la MISMA fila de /api/Invoices)", }, }; console.log( ` pagadas: ${paidChecks.length} muestras, todas saldadas=${paidChecks.every((c) => c.settled)} · activas: ${activeChecks.length} muestras, todas con residual=${activeChecks.every((c) => c.residualPositive)}`, ); // XML del CFDI (quedó sin probar en ronda 3 por el break temprano) const id = paid.rows?.[0] ? String((paid.rows[0] as Record)["ID"] ?? "") : ""; if (id) { const xml = await probeGet(`/api/Invoices/${id}/xml`); const doc = ((report as Record)["documentDownload"] ?? {}) as Record; doc["Invoices/{id}/xml"] = { status: xml.status, contentType: xml.contentType }; (report as Record)["documentDownload"] = doc; console.log(` xml → ${xml.status} (${xml.contentType})`); } } // ─── Main ─────────────────────────────────────────────────────────────────── async function main() { const round2 = process.argv.includes("--round2"); const round3 = process.argv.includes("--round3"); const round4 = process.argv.includes("--round4"); console.log(`BIND API · validación técnica SOLO LECTURA${round2 ? " · RONDA 2" : round3 ? " · RONDA 3" : round4 ? " · RONDA 4" : ""}`); console.log(`Base: ${BASE} · presupuesto: ${BUDGET} peticiones`); if (!TOKEN) { console.error("❌ No hay BIND_API_TOKEN en bind-api-sandbox/.env — abortando."); process.exitCode = 1; return; } console.log("Token cargado desde .env (no se imprime)."); if (/localhost|127\.0\.0\.1/.test(BASE)) { console.log("⚠️ Base URL apunta al mock local; esto NO valida producción."); } if (round2 || round3 || round4) { if (!loadPreviousReport()) { console.error("❌ --round2/--round3/--round4 requieren validation-output/report.json previo."); process.exitCode = 1; return; } used = report.meta.requestsUsed; // presupuesto acumulado entre rondas if (round2) { await phasePaymentsHunt(); await phaseStatusSemantics(); await phasePrefactura(); await phaseDetailShapes(); await phasePagination(); await phaseSeriesHunt(); } else if (round3) { await phaseCaps(); await phaseCfdiLiterals(); await phasePaymentsHuntEs(); await phaseDocEndpoints(); } else { await phaseBalanceArithmetic(); } phaseBalanceVerdict(); finish(); return; } const auth = await phaseAuth(); if (auth !== "ok") { finish(); return; } const inventory = await phaseInventory(); const rowsByResource = await phaseShapes(inventory); await phaseOData(rowsByResource); await phaseById(rowsByResource); phaseBalanceVerdict(); phaseMultiCompany(inventory); finish(); } function finish() { report.meta.requestsUsed = used; mkdirSync(OUT_DIR, { recursive: true }); const serialized = scrub(JSON.stringify(report, null, 2)); writeFileSync(join(OUT_DIR, "report.json"), serialized, "utf8"); console.log(`\n✔ Reporte sanitizado escrito en validation-output/report.json`); console.log(`✔ Peticiones usadas: ${used}/${BUDGET}`); } main().catch((err) => { console.error("Error fatal:", scrub(String(err?.message ?? err))); finish(); process.exitCode = 1; });