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:
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user