Files
balam/bind-api-sandbox/src/validate-real-api.ts
T
JohannVelazquez 9ccfbe262e Validación técnica de la API de BIND contra producción (GET-only)
Plan A del saldo CONFIRMADO (Total − Payments − CreditNotes, misma fila);
flujo MVP sostenible 100% en lectura; PDF del CFDI por API. Hallazgo duro:
sin recurso de pagos individuales ni REP (escalar a Pedro). Cliente extendido
(Quotes, Currencies, Locations, pdf/xml, GET por ID estilo REST) y tipos
reales en types.real.ts. Detalle en bind-api-sandbox/VALIDACION-API.md;
reporte crudo y token gitignoreados.
2026-07-06 17:17:27 -06:00

858 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> {
const out: Record<string, string> = {};
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<string, string>; // 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<Probe> {
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<string, string> = { 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<string, string> = {};
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<Probe, "rows" | "bodyShape" | "count" | "nextLink"> {
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<string, unknown>;
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<string, { types: Set<string>; formats: Set<string>; nullable: boolean; enums: Set<string> }>();
const visit = (obj: Record<string, unknown>, 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<string, unknown>, `${k}[].`);
} else if (!prefix && typeof v === "object" && !Array.isArray(v)) {
visit(v as Record<string, unknown>, `${k}.`);
}
}
};
for (const row of rows.slice(0, cap)) {
if (typeof row === "object" && row !== null && !Array.isArray(row)) {
visit(row as Record<string, unknown>, "");
}
}
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<void> {
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<string, unknown>,
inventory: [] as InventoryEntry[],
shapes: {} as Record<string, { rowsAnalyzed: number; totalCount: number | string | null; fields: FieldInfo[] }>,
odata: {} as Record<string, unknown>,
byId: {} as Record<string, unknown>,
multiCompany: {} as Record<string, unknown>,
balanceQuestion: {} as Record<string, unknown>,
headersObserved: {} as Record<string, string>,
paymentsHunt: {} as Record<string, unknown>,
statusSemantics: {} as Record<string, unknown>,
prefactura: {} as Record<string, unknown>,
pagination: {} as Record<string, unknown>,
seriesHunt: {} as Record<string, unknown>,
};
function projection(p: Probe): Omit<Probe, "rows"> {
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<string> {
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 <token> (ú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<Map<string, Probe>> {
console.log("\n── Fase 2 · Inventario de recursos (GET {recurso}?$top=1)");
const results = new Map<string, Probe>();
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<string, Probe>): Promise<Map<string, unknown[]>> {
console.log("\n── Fase 3 · Inventario de campos ($top=5, solo estructura)");
const rowsByResource = new Map<string, unknown[]>();
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<string, unknown[]>): Promise<void> {
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<string, unknown> = { 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<string, unknown>)[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<string, unknown>)[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<string, unknown[]>): Promise<void> {
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<string, unknown>)[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<string, Probe>): 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<void> {
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<string, number> = {};
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<string, unknown>)["ID"] ?? "") : "";
const subProbes: Record<string, number> = {};
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<void> {
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<string, string> = {};
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<string, unknown>;
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<string, string> = {};
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<string, unknown>)["ID"] ?? "");
if (!id) continue;
const det = await probeGet(`/api/Invoices/${id}`);
if (det.ok && det.rows?.length) {
const d = det.rows[0] as Record<string, unknown>;
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<void> {
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<void> {
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<string, unknown>)["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<void> {
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<void> {
console.log("\n── Ronda 2 · Series de facturación");
const out: Record<string, number> = {};
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<void> {
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<string, unknown>),
"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<void> {
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<string, unknown>)["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<void> {
console.log("\n── Ronda 3 · Pagos: nombres en español y variantes finales");
const extra: Record<string, number> = {};
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<string, unknown>), spanishAndFinal: extra };
console.log(` ${JSON.stringify(extra)}`);
}
async function phaseDocEndpoints(): Promise<void> {
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<string, unknown>)["ID"] ?? "") : "";
const out: Record<string, unknown> = {};
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<string, unknown>)["documentDownload"] = out;
console.log(` ${JSON.stringify(out)}`);
}
// ─── Ronda 4: verificación aritmética del saldo (en memoria, sin persistir montos) ───
async function phaseBalanceArithmetic(): Promise<void> {
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<string, unknown>;
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<string, unknown>),
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<string, unknown>)["ID"] ?? "") : "";
if (id) {
const xml = await probeGet(`/api/Invoices/${id}/xml`);
const doc = ((report as Record<string, unknown>)["documentDownload"] ?? {}) as Record<string, unknown>;
doc["Invoices/{id}/xml"] = { status: xml.status, contentType: xml.contentType };
(report as Record<string, unknown>)["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;
});