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.
This commit is contained in:
JohannVelazquez
2026-07-06 17:17:27 -06:00
parent 12006b5fa4
commit 9ccfbe262e
10 changed files with 1697 additions and 27 deletions
+857
View File
@@ -0,0 +1,857 @@
/**
* 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;
});