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
+110 -4
View File
@@ -6,9 +6,22 @@
* - dry-run: loguea el request que se haría sin enviarlo (útil para revisar
* un payload antes de aprobarlo manualmente).
* - Retries con backoff exponencial en 429 y 5xx (no en 4xx fuera de 429).
* El API real emite 500 transitorios ocasionales — confirmado 6-jul-2026.
* - Lleva contador local de requests para acercarse al límite de 20K/día
* con visibilidad temprana (cuota real la valida el servidor).
* con visibilidad temprana (la cuota NO es observable en headers — confirmado).
* - Sin dependencias externas — usa fetch nativo de Node 20+.
*
* Reconciliado contra el API real (validación 6-jul-2026, ver VALIDACION-API.md):
* - Auth: SOLO `Authorization: Bearer` — el Ocp-Apim-Subscription-Key no se requiere.
* - GET por ID: estilo REST `/api/{Recurso}/{id}` (el estilo OData `(guid'...')`
* responde 404 en producción). El mock local sigue usando `(guid'...')`, por
* eso el estilo es configurable (`idStyle`).
* - Recursos reales: `Clients` (no Customers), `Quotes`, `Currencies`,
* `Warehouses`, `Locations`. NO existe recurso `Payments` — el acumulado
* pagado viene en cada factura (campo `Payments`).
* - `$select` NO funciona (500) y `$top` acepta máximo 100.
* - Token inválido → 500 con body "API Key es inválida" (no 401) — no
* reintentamos 500 cuyo body reporte api key inválida.
*/
import { buildQueryString, type ODataQuery } from "./odata.js";
@@ -20,14 +33,35 @@ import type {
Payment,
Product,
} from "./types.js";
import type {
BindCollection,
ClientDetail,
ClientListItem,
CurrencyInfo,
InvoiceDetail,
InvoiceListItem,
LocationInfo,
QuoteDetail,
QuoteListItem,
WarehouseInfo,
} from "./types.real.js";
export type ClientMode = "read-only" | "dry-run" | "write";
/**
* Estilo del GET por ID:
* - "rest": /api/Invoices/{id} → lo que el API REAL acepta (confirmado 6-jul-2026).
* - "odata": /api/Invoices(guid'{id}') → lo que implementa el mock local.
*/
export type IdStyle = "rest" | "odata";
export interface BindClientConfig {
baseUrl: string;
apiKey: string;
subscriptionKey?: string;
mode?: ClientMode;
/** Default "rest" (API real). Usa "odata" contra el mock local. */
idStyle?: IdStyle;
/** Máximo de reintentos para 429/5xx. */
maxRetries?: number;
/** Logger opcional. Default: console. */
@@ -60,6 +94,7 @@ export class BindClient {
private readonly apiKey: string;
private readonly subscriptionKey?: string;
private readonly mode: ClientMode;
private readonly idStyle: IdStyle;
private readonly maxRetries: number;
private readonly logger: Pick<Console, "info" | "warn" | "error">;
private readonly fetchImpl: typeof fetch;
@@ -72,19 +107,78 @@ export class BindClient {
this.apiKey = cfg.apiKey;
this.subscriptionKey = cfg.subscriptionKey;
this.mode = cfg.mode ?? "read-only";
this.idStyle = cfg.idStyle ?? "rest";
this.maxRetries = cfg.maxRetries ?? 3;
this.logger = cfg.logger ?? console;
this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
}
// --- Recursos del MVP --------------------------------------------------
private byId(resource: string, id: string): string {
return this.idStyle === "rest"
? `/api/${resource}/${id}`
: `/api/${resource}(guid'${id}')`;
}
// --- Recursos REALES confirmados (validación 6-jul-2026) ----------------
/** GET /api/Invoices — lista con acumulados Payments/CreditNotes (saldo = Total ambos). */
invoiceList(query: ODataQuery = {}): Promise<BindCollection<InvoiceListItem>> {
return this.get<BindCollection<InvoiceListItem>>(`/api/Invoices${buildQueryString(query)}`);
}
/** GET /api/Invoices/{id} — única fuente de PPD/PUE (CFDIPaymentTerm) y días de crédito. */
invoiceDetail(id: string): Promise<InvoiceDetail> {
return this.get<InvoiceDetail>(this.byId("Invoices", id));
}
/** GET /api/Clients — así se llaman los clientes en el API real (no Customers). */
clients(query: ODataQuery = {}): Promise<BindCollection<ClientListItem>> {
return this.get<BindCollection<ClientListItem>>(`/api/Clients${buildQueryString(query)}`);
}
/** GET /api/Clients/{id} — trae CreditDays, contactos y (sic) Loctaion/LoctaionID. */
clientDetail(id: string): Promise<ClientDetail> {
return this.get<ClientDetail>(this.byId("Clients", id));
}
/** GET /api/Quotes — cotizaciones (0=Activa, 1=Cancelada, 2=Surtida). */
quotes(query: ODataQuery = {}): Promise<BindCollection<QuoteListItem>> {
return this.get<BindCollection<QuoteListItem>>(`/api/Quotes${buildQueryString(query)}`);
}
/** GET /api/Quotes/{id} — partidas Items[]; SIN referencia a la factura generada. */
quoteDetail(id: string): Promise<QuoteDetail> {
return this.get<QuoteDetail>(this.byId("Quotes", id));
}
currencies(query: ODataQuery = {}): Promise<BindCollection<CurrencyInfo>> {
return this.get<BindCollection<CurrencyInfo>>(`/api/Currencies${buildQueryString(query)}`);
}
warehouses(query: ODataQuery = {}): Promise<BindCollection<WarehouseInfo>> {
return this.get<BindCollection<WarehouseInfo>>(`/api/Warehouses${buildQueryString(query)}`);
}
locations(query: ODataQuery = {}): Promise<BindCollection<LocationInfo>> {
return this.get<BindCollection<LocationInfo>>(`/api/Locations${buildQueryString(query)}`);
}
/** GET /api/Invoices/{id}/pdf — devuelve el PDF binario del CFDI (insumo del módulo de envío). */
async invoicePdf(id: string): Promise<ArrayBuffer> {
return this.getBinary(`/api/Invoices/${id}/pdf`);
}
// --- Recursos de la era mock (types.ts aproximados) ----------------------
// El mock server sirve Customers/Payments con el schema aproximado previo a la
// validación. Se conservan para la demo local; NO usarlos contra producción
// (Customers → 404 real; Payments → 404 real — no existe el recurso).
customers(query: ODataQuery = {}): Promise<ODataCollection<Customer>> {
return this.get<ODataCollection<Customer>>(`/api/Customers${buildQueryString(query)}`);
}
customer(id: string): Promise<Customer> {
return this.get<Customer>(`/api/Customers(guid'${id}')`);
return this.get<Customer>(this.byId("Customers", id));
}
invoices(query: ODataQuery = {}): Promise<ODataCollection<Invoice>> {
@@ -92,7 +186,7 @@ export class BindClient {
}
invoice(id: string): Promise<Invoice> {
return this.get<Invoice>(`/api/Invoices(guid'${id}')`);
return this.get<Invoice>(this.byId("Invoices", id));
}
payments(query: ODataQuery = {}): Promise<ODataCollection<Payment>> {
@@ -133,6 +227,18 @@ export class BindClient {
return this.request<T>("GET", path);
}
/** GET binario (PDF del CFDI). Cuenta contra la cuota como cualquier request. */
private async getBinary(path: string): Promise<ArrayBuffer> {
this.rolloverIfNewDay();
this.requestCount++;
const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` };
if (this.subscriptionKey) headers["Ocp-Apim-Subscription-Key"] = this.subscriptionKey;
const url = `${this.baseUrl}${path}`;
const res = await this.fetchImpl(url, { method: "GET", headers });
if (!res.ok) throw new BindApiError(res.status, url, await safeJson(res));
return res.arrayBuffer();
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
if (MUTATING.has(method) && this.mode === "read-only") {
throw new BindReadOnlyViolation(method, path);
+306
View File
@@ -0,0 +1,306 @@
/**
* Tipos CONFIRMADOS contra el API real de BIND (validación del 6-jul-2026,
* cuenta Balam, solo lectura). Fuente: VALIDACION-API.md + validation-output/report.json.
*
* Conviven con types.ts (la aproximación que consume el mock server): el mock
* queda intacto; el código que apunte a producción debe tipar con ESTOS.
*
* Notas duras del API real:
* - Los clientes son `Clients` (no `Customers`); no existe recurso `Payments`.
* - GET por ID es estilo REST (`/api/Invoices/{id}`), NO OData `(guid'...')`.
* - La lista y el detalle de un mismo recurso difieren en campos y hasta en
* nombres (`Serie` vs `Series`; `Status` int vs string+`StatusCode`).
* - `CFDIPaymentTerm` = Método de pago SAT (PPD/PUE) y `CFDIPaymentMethod` =
* Forma de pago SAT — nomenclatura invertida respecto al SAT.
* - `Loctaion`/`LoctaionID` es un typo real del API en el detalle de Clients.
* - Saldo por factura = Total Payments CreditNotes (misma fila de lista).
*/
export type Guid = string;
export type IsoDateTime = string; // "2026-07-06T00:00:00" (sin zona en lo observado)
// ─── Invoices ───────────────────────────────────────────────────────────────
/** Códigos de Invoices.Status confirmados vía filtros + detalle. */
export enum InvoiceStatusCode {
Activa = 0,
Pagada = 1,
Cancelada = 2,
}
/** Fila de GET /api/Invoices (lista, 32 campos). */
export interface InvoiceListItem {
ID: Guid;
/** ⚠️ En el detalle este campo se llama `Series`. */
Serie: string;
Number: number;
/** Folio fiscal. `null` ⇒ prefactura (sin timbrar). */
UUID: Guid | null;
Date: IsoDateTime;
/** Vencimiento — insumo del aging. No existe `DueDate`. */
ExpirationDate: IsoDateTime;
ClientID: Guid;
ClientName: string;
RFC: string;
Cost: number;
Subtotal: number;
Discount: number;
VAT: number;
IEPS: number;
ISRRet: number;
VATRet: number;
Total: number;
/** Acumulado PAGADO de la factura (no es una colección). */
Payments: number;
/** Acumulado de notas de crédito aplicadas. */
CreditNotes: number;
CurrencyID: Guid;
LocationID: Guid;
WarehouseID: Guid;
PriceListID: Guid;
/** Código INTERNO de BIND (se observaron 3, 23) — no es la clave SAT (G03…). */
CFDIUse: number;
ExchangeRate: number;
VATRetRate: number;
Comments: string;
VATRate: number;
PurchaseOrder: string;
/** false ⇒ prefactura. */
IsFiscalInvoice: boolean;
ShowIEPS: boolean;
Status: InvoiceStatusCode;
}
/** Partida de servicios del detalle de factura. */
export interface InvoiceServiceLine {
ID: Guid;
IndexNumber: number;
ServiceID: Guid;
Name: string;
Code: string;
Qty: number;
Price: number;
/** Tasa de IVA por partida — habilita la validación 16 % MXN / 0 % extranjero. */
VATRate: number;
Discount: number;
}
/** GET /api/Invoices/{id} (detalle, 50 campos). Campos exclusivos vs lista. */
export interface InvoiceDetail {
ID: Guid;
UUID: Guid | null;
/** ⚠️ La lista lo llama `Serie`. */
Series: string;
Number: number;
ClientID: Guid;
ClientName: string;
/** Días de crédito de la factura — solo en detalle. */
PaymentTerms: number;
/** Etiqueta legible ("Activa" | "Pagada" | "Cancelada"). */
Status: string;
StatusCode: InvoiceStatusCode;
ClientPhoneNumber: string | null;
ClientContact: string | null;
RFC: string;
CreatedByID: Guid;
CreatedByName: string;
CreationDate: IsoDateTime;
ApplicationDate: IsoDateTime;
PriceListID: Guid;
PriceListName: string;
LocationID: Guid;
LocationName: string;
WarehouseID: Guid;
WarehouseName: string;
/** ⚠️ FORMA de pago SAT (ej. "Transferencia Electrónica de Fondos", "Por Definir"). */
CFDIPaymentMethod: string;
/** ⚠️ MÉTODO de pago SAT — PPD/PUE (ej. "PAGO EN UNA SOLA EXHIBICIÓN"). Puede venir vacío. */
CFDIPaymentTerm: string;
CFDIAccountNumber: string;
/** Código de 3 letras ("MXN"/"USD") — a pesar del nombre. */
CurrencyName: string;
ExchangeRate: number;
PurchaseOrder: string;
FiscalID: Guid;
Address: string;
Comments: string;
Subtotal: number;
Discount: number;
VAT: number;
IEPS: number;
VATRet: number;
ISRRet: number;
Payments: number;
CreditNotes: number;
Products: unknown[]; // partidas de producto (vacío en Balam — facturan servicios)
Services: InvoiceServiceLine[];
}
/** Saldo abierto por factura (Plan A operativo — ver VALIDACION-API.md §4). */
export function invoiceOpenBalance(inv: Pick<InvoiceListItem, "Total" | "Payments" | "CreditNotes">): number {
return inv.Total - inv.Payments - inv.CreditNotes;
}
// ─── Clients ────────────────────────────────────────────────────────────────
/** Fila de GET /api/Clients (lista, 10 campos). */
export interface ClientListItem {
ID: Guid;
Number: number;
ClientName: string;
LegalName: string;
RFC: string;
Email: string | null;
Phone: string | null;
NextContactDate: IsoDateTime | null;
LocationID: Guid;
RegimenFiscal: string;
}
/** GET /api/Clients/{id} (detalle, 28 campos). */
export interface ClientDetail {
ID: Guid;
RFC: string;
LegalName: string;
CommercialName: string;
/** Días de crédito default del cliente (30/45/90 del Discovery). */
CreditDays: number;
CreditAmount: number;
PaymentMethod: string;
CreationDate: IsoDateTime;
Status: string;
SalesContact: string;
CreditContact: string;
/** ⚠️ Typo REAL del API (sic). */
Loctaion: string;
/** ⚠️ Typo REAL del API (sic). */
LoctaionID: Guid;
Comments: string;
PriceList: string;
PriceListID: Guid;
PaymentTermType: string;
Email: string | null;
Telephones: string | null;
Number: number;
AccountNumber: string | null;
DefaultDiscount: number | null;
ClientSource: string;
Account: string;
City: string;
State: string;
Addresses: unknown[];
RegimenFiscal: string;
}
// ─── Quotes ─────────────────────────────────────────────────────────────────
/** Códigos de Quotes.Status confirmados (StatusText de la misma fila). */
export enum QuoteStatusCode {
Activa = 0,
Cancelada = 1,
Surtida = 2,
}
/** Fila de GET /api/Quotes (lista, 11 campos). */
export interface QuoteListItem {
ID: Guid;
Number: string;
CreationDate: IsoDateTime;
ClientName: string;
Locations: string;
Comments: string | null;
TotalOriginalCurrency: number;
/** Nombre ("Peso mexicano") — el código de 3 letras vive en el detalle. */
Currency: string;
Status: QuoteStatusCode;
Total: number;
StatusText: string | null;
}
/** Partida del detalle de cotización. */
export interface QuoteItem {
ID: Guid;
Code: string;
ProductID: Guid;
ProductName: string;
Unit: string;
Qty: number;
Price: number;
Amount: number;
IEPS: number;
VAT: number;
IndexNumber: number;
}
/** GET /api/Quotes/{id} (detalle, 45 campos).
* ⚠️ NO trae referencia a la factura generada — la trazabilidad la lleva la plataforma. */
export interface QuoteDetail {
ID: Guid;
QuoteNumber: string;
ClientName: string;
ClientContact: string;
ClientID: Guid;
ClientPhone: string | null;
LocationName: string;
LocationID: Guid;
PriceListName: string;
PriceListID: Guid;
EmployeeName: string;
EmployeeID: Guid;
CurrencyCode: string;
ExchangeRate: number;
CreationDate: IsoDateTime;
Status: QuoteStatusCode;
StatusText: string | null;
Subtotal: number;
Discount: number;
IEPS: number;
VAT: number;
VATRate: number;
ISR: number;
ISRRate: number;
Total: number;
BaseCurrency: boolean;
Comments: string | null;
OriginalCurrencyDiscountAmount: number;
OriginalCurrencySubtotal: number;
IsPercentage: boolean;
ContactEmails: string | null;
ExternalIDType: number;
VatRet: number;
Items: QuoteItem[];
}
// ─── Catálogos ──────────────────────────────────────────────────────────────
export interface CurrencyInfo {
ID: Guid;
Name: string;
Code: string; // "MXN", "USD"…
ExchangeRate: number;
}
export interface WarehouseInfo {
ID: Guid;
Name: string;
LocationID: Guid;
AvailableInOtherLoc: boolean;
}
export interface LocationInfo {
ID: Guid;
Name: string;
Street: string;
ExtNumber: string;
IntNumber: string;
ZipCode: string;
Colonia: string;
City: string;
State: string;
}
/** Respuesta de colección del API real: { value: [...] } sin count ni nextLink
* (el conteo total NO es accesible; paginar con $top=100 + $skip). */
export interface BindCollection<T> {
value: T[];
}