@@ -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 ;
} ) ;