Reorganiza repo como fuente de la verdad + propuesta v1.0 con emisión de facturas

- Estructura nueva: README maestro, bitacora/ (REGISTRO, PENDIENTES, plantillas),
  propuesta/, fuentes/; material superado a _archivado/
- Propuesta v1.0: MVP BIND-first con emisión asistida MXN/USD (dry-run +
  confirmación, timbra PAC de BIND), 112-136 h / $67,200-$81,600 + IVA,
  stack .NET 10 + EF Core + Angular 21 + PostgreSQL 17 sobre Azure
- Bitácora: historial de correos + 2 llamadas (incl. revisión 4-jun) y pendientes
- Prototipo y diagrama actualizados a v1.0; precios de Azure verificados
- Archivo ajeno (proyecto EOS) retirado del repo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JohannVelazquez
2026-06-04 10:57:27 -06:00
parent 404e6f3b89
commit 633d05e330
47 changed files with 32977 additions and 472 deletions
+220
View File
@@ -0,0 +1,220 @@
/**
* Cliente del API de BIND ERP.
*
* Decisiones:
* - Modo seguro por default (read-only): bloquea cualquier método mutante.
* - 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).
* - Lleva contador local de requests para acercarse al límite de 20K/día
* con visibilidad temprana (cuota real la valida el servidor).
* - Sin dependencias externas — usa fetch nativo de Node 20+.
*/
import { buildQueryString, type ODataQuery } from "./odata.js";
import type {
Activity,
Customer,
Invoice,
ODataCollection,
Payment,
Product,
} from "./types.js";
export type ClientMode = "read-only" | "dry-run" | "write";
export interface BindClientConfig {
baseUrl: string;
apiKey: string;
subscriptionKey?: string;
mode?: ClientMode;
/** Máximo de reintentos para 429/5xx. */
maxRetries?: number;
/** Logger opcional. Default: console. */
logger?: Pick<Console, "info" | "warn" | "error">;
/** Inyectable para tests. Default: globalThis.fetch. */
fetchImpl?: typeof fetch;
}
export class BindApiError extends Error {
constructor(
public readonly status: number,
public readonly url: string,
public readonly body: unknown,
) {
super(`BIND API ${status} on ${url}`);
}
}
export class BindReadOnlyViolation extends Error {
constructor(method: string, path: string) {
super(`Read-only mode bloqueó ${method} ${path}. Cambia BIND_MODE=write si tienes autorización.`);
}
}
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const DAILY_QUOTA = 20_000;
export class BindClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly subscriptionKey?: string;
private readonly mode: ClientMode;
private readonly maxRetries: number;
private readonly logger: Pick<Console, "info" | "warn" | "error">;
private readonly fetchImpl: typeof fetch;
private requestCount = 0;
private dayBucket = currentDayBucket();
constructor(cfg: BindClientConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
this.apiKey = cfg.apiKey;
this.subscriptionKey = cfg.subscriptionKey;
this.mode = cfg.mode ?? "read-only";
this.maxRetries = cfg.maxRetries ?? 3;
this.logger = cfg.logger ?? console;
this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
}
// --- Recursos del MVP --------------------------------------------------
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}')`);
}
invoices(query: ODataQuery = {}): Promise<ODataCollection<Invoice>> {
return this.get<ODataCollection<Invoice>>(`/api/Invoices${buildQueryString(query)}`);
}
invoice(id: string): Promise<Invoice> {
return this.get<Invoice>(`/api/Invoices(guid'${id}')`);
}
payments(query: ODataQuery = {}): Promise<ODataCollection<Payment>> {
return this.get<ODataCollection<Payment>>(`/api/Payments${buildQueryString(query)}`);
}
products(query: ODataQuery = {}): Promise<ODataCollection<Product>> {
return this.get<ODataCollection<Product>>(`/api/Products${buildQueryString(query)}`);
}
activities(query: ODataQuery = {}): Promise<ODataCollection<Activity>> {
return this.get<ODataCollection<Activity>>(`/api/Activities${buildQueryString(query)}`);
}
/**
* Escritura controlada: dejado disponible para cuando el discovery
* confirme que es seguro. Por default el mode bloquea el método.
*/
addActivity(activity: Omit<Activity, "ID" | "CreatedAt">): Promise<Activity> {
return this.request<Activity>("POST", "/api/Activities", activity);
}
// --- Estado / observabilidad ------------------------------------------
stats(): { requestsToday: number; quota: number; remaining: number; mode: ClientMode } {
this.rolloverIfNewDay();
return {
requestsToday: this.requestCount,
quota: DAILY_QUOTA,
remaining: Math.max(0, DAILY_QUOTA - this.requestCount),
mode: this.mode,
};
}
// --- Implementación HTTP ----------------------------------------------
private get<T>(path: string): Promise<T> {
return this.request<T>("GET", path);
}
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);
}
this.rolloverIfNewDay();
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
Accept: "application/json",
};
if (this.subscriptionKey) headers["Ocp-Apim-Subscription-Key"] = this.subscriptionKey;
if (body !== undefined) headers["Content-Type"] = "application/json";
if (this.mode === "dry-run" && MUTATING.has(method)) {
this.logger.info("[bind][dry-run]", method, url, body ?? "");
return undefined as T;
}
let lastErr: unknown;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
this.requestCount++;
const res = await this.fetchImpl(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.ok) {
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
const errBody = await safeJson(res);
if (res.status === 429 || res.status >= 500) {
if (attempt < this.maxRetries) {
const waitMs = backoffMs(attempt, res.headers.get("Retry-After"));
this.logger.warn(
`[bind] ${res.status} en ${path}, retry ${attempt + 1}/${this.maxRetries} en ${waitMs}ms`,
);
await sleep(waitMs);
continue;
}
}
throw new BindApiError(res.status, url, errBody);
} catch (err) {
lastErr = err;
if (err instanceof BindApiError) throw err;
if (attempt >= this.maxRetries) break;
await sleep(backoffMs(attempt, null));
}
}
throw lastErr ?? new Error("request failed");
}
private rolloverIfNewDay() {
const now = currentDayBucket();
if (now !== this.dayBucket) {
this.dayBucket = now;
this.requestCount = 0;
}
}
}
function backoffMs(attempt: number, retryAfter: string | null): number {
if (retryAfter) {
const secs = Number(retryAfter);
if (Number.isFinite(secs)) return secs * 1000;
}
return Math.min(1000 * 2 ** attempt, 8000) + Math.floor(Math.random() * 250);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function safeJson(res: Response): Promise<unknown> {
try {
return await res.json();
} catch {
return null;
}
}
function currentDayBucket(): string {
return new Date().toISOString().slice(0, 10);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Helpers para construir queries OData que entiende el API de BIND.
*
* Sintaxis observable en la doc oficial:
* /api/Products?$filter=ID eq guid'bbe2cc0c-...'&$skip=0&$top=50&$orderby=Name asc
*
* Mantengo el builder muy chico — sólo lo que el MVP necesita.
*/
export type ODataFilter = string;
export interface ODataQuery {
filter?: ODataFilter;
top?: number;
skip?: number;
orderby?: string;
select?: string[];
count?: boolean;
}
export function buildQueryString(q: ODataQuery): string {
const parts: string[] = [];
if (q.filter) parts.push(`$filter=${encodeURIComponent(q.filter)}`);
if (typeof q.top === "number") parts.push(`$top=${q.top}`);
if (typeof q.skip === "number") parts.push(`$skip=${q.skip}`);
if (q.orderby) parts.push(`$orderby=${encodeURIComponent(q.orderby)}`);
if (q.select?.length) parts.push(`$select=${encodeURIComponent(q.select.join(","))}`);
if (q.count) parts.push(`$count=true`);
return parts.length ? `?${parts.join("&")}` : "";
}
/**
* Pequeño helper para escribir filtros legibles. No es un parser OData;
* solo escapa comillas simples y envuelve guids.
*
* Ejemplos:
* eq("ID", guid("bbe2...")) -> "ID eq guid'bbe2...'"
* eq("Status", "issued") -> "Status eq 'issued'"
* and(eq("Status","issued"), gt("Total", 1000))
*/
export const guid = (id: string): string => `guid'${id.replaceAll("'", "''")}'`;
export const str = (s: string): string => `'${s.replaceAll("'", "''")}'`;
export const eq = (field: string, value: string | number | boolean): string =>
`${field} eq ${formatValue(value)}`;
export const ne = (field: string, value: string | number | boolean): string =>
`${field} ne ${formatValue(value)}`;
export const gt = (field: string, value: string | number): string =>
`${field} gt ${formatValue(value)}`;
export const lt = (field: string, value: string | number): string =>
`${field} lt ${formatValue(value)}`;
export const ge = (field: string, value: string | number): string =>
`${field} ge ${formatValue(value)}`;
export const le = (field: string, value: string | number): string =>
`${field} le ${formatValue(value)}`;
export const and = (...parts: string[]): string => parts.join(" and ");
export const or = (...parts: string[]): string => `(${parts.join(" or ")})`;
function formatValue(v: string | number | boolean): string {
if (typeof v === "number" || typeof v === "boolean") return String(v);
// Si ya viene formateado como guid'...' o '...' (string OData), respétalo.
if (/^(guid'.*'|'.*')$/.test(v)) return v;
return str(v);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Tipos del dominio de BIND ERP, modelados a partir de la documentación pública
* y de la convención observable del API (OData-like sobre Azure API Management).
*
* Estos tipos son una aproximación: la documentación detallada vive detrás de
* login en developers.bind.com.mx. Cuando se obtenga el API key se deben
* reconciliar contra el schema real (especialmente nombres exactos de campos).
*/
export type Guid = string; // BIND identifica recursos como guid'...' en filtros OData.
export type IsoDate = string; // ISO 8601, ej. "2026-05-28T10:00:00Z"
export type Decimal = number; // En producción debe envolverse a decimal(18,4) en Balam.
export type Currency = "MXN" | "USD" | "EUR";
export interface Customer {
ID: Guid;
Code: string;
Name: string;
TaxId: string; // RFC en MX, TaxID/EIN en US.
Country: string;
Email: string | null;
Currency: Currency;
PaymentTerms: number | null; // Días de crédito.
IsActive: boolean;
CreatedAt: IsoDate;
UpdatedAt: IsoDate;
}
export interface Product {
ID: Guid;
Code: string;
Name: string;
UnitPrice: Decimal;
Currency: Currency;
SatCode: string | null; // Catálogo SAT (ClaveProdServ).
IsActive: boolean;
}
export type InvoiceStatus = "draft" | "issued" | "paid" | "partial" | "overdue" | "cancelled";
export interface InvoiceLine {
ProductID: Guid;
Description: string;
Quantity: Decimal;
UnitPrice: Decimal;
TaxRate: Decimal; // ej. 0.16 = IVA 16 %
Subtotal: Decimal;
Total: Decimal;
}
export interface Invoice {
ID: Guid;
Folio: string;
Serie: string;
UUID: string | null; // UUID del CFDI cuando ya fue timbrada por el PAC integrado de BIND.
CustomerID: Guid;
IssueDate: IsoDate;
DueDate: IsoDate;
Currency: Currency;
ExchangeRate: Decimal | null; // TC al momento de emisión (relevante para USD/EUR).
Subtotal: Decimal;
Taxes: Decimal;
Total: Decimal;
Balance: Decimal; // Saldo pendiente.
Status: InvoiceStatus;
Lines: InvoiceLine[];
XmlUrl: string | null;
PdfUrl: string | null;
}
export interface Payment {
ID: Guid;
InvoiceID: Guid;
PaymentDate: IsoDate;
Amount: Decimal;
Currency: Currency;
Method: "cash" | "transfer" | "card" | "check" | "other";
Reference: string | null;
}
export interface Activity {
ID: Guid;
CustomerID: Guid | null;
InvoiceID: Guid | null;
Type: string;
Subject: string;
Notes: string | null;
CreatedAt: IsoDate;
CreatedBy: string;
}
/**
* Respuesta paginada estilo OData v3/v4: la API responde con
* { value: [...], "odata.count"?: number, "odata.nextLink"?: string }.
* Modelamos solo lo que necesita el cliente.
*/
export interface ODataCollection<T> {
value: T[];
count?: number;
nextLink?: string;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Demo end-to-end: cinco escenarios que tocan los casos críticos del MVP
* de Balam (BIND-first) tal como salieron en el discovery.
*
* 1. Listar clientes activos -> base del dashboard
* 2. Filtrar facturas vencidas (cobranza) -> motor de recordatorios
* 3. Calcular CxC por cliente -> KPI directivo
* 4. Detectar factura USD a cliente extranjero -> regla sin IVA + TC
* 5. Intentar una escritura en modo read-only -> demuestra el guardrail
*
* Por default apunta al mock local. Para correrlo contra producción:
* BIND_BASE_URL=https://api.bind.com.mx \
* BIND_API_KEY=<perfil-usuario/integraciones> \
* BIND_SUBSCRIPTION_KEY=<si-aplica> \
* pnpm demo
*/
import { BindClient, BindReadOnlyViolation } from "./client/BindClient.js";
import { and, eq, ge, guid, lt } from "./client/odata.js";
const cfg = {
baseUrl: process.env.BIND_BASE_URL ?? "http://localhost:4010",
apiKey: process.env.BIND_API_KEY ?? "mock-bearer-token",
subscriptionKey: process.env.BIND_SUBSCRIPTION_KEY,
mode: (process.env.BIND_MODE as "read-only" | "dry-run" | "write") ?? "read-only",
};
const client = new BindClient(cfg);
async function main() {
banner(`BIND API sandbox — modo: ${cfg.mode} · base: ${cfg.baseUrl}`);
// ── 1. Clientes activos ───────────────────────────────────────────────
step("1. Listar clientes activos (sería el seed del dashboard)");
const active = await client.customers({
filter: eq("IsActive", true),
orderby: "Name asc",
top: 50,
count: true,
});
console.table(
active.value.map((c) => ({
Code: c.Code,
Name: c.Name,
Currency: c.Currency,
Country: c.Country,
Terms: c.PaymentTerms,
})),
);
// ── 2. Facturas vencidas ──────────────────────────────────────────────
step("2. Facturas vencidas — motor de cobranza");
const today = new Date().toISOString();
const overdue = await client.invoices({
filter: and(eq("Status", "overdue"), lt("DueDate", `'${today}'`)),
orderby: "DueDate asc",
});
console.table(
overdue.value.map((i) => ({
Folio: `${i.Serie}-${i.Folio}`,
Cliente: shortId(i.CustomerID),
DueDate: i.DueDate.slice(0, 10),
Currency: i.Currency,
Balance: i.Balance,
})),
);
// ── 3. Aging por cliente (cuentas por cobrar) ─────────────────────────
step("3. CxC por cliente (sólo MXN para simplificar el demo)");
const open = await client.invoices({
filter: and(eq("Currency", "MXN"), ge("Balance", 0.01)),
});
const byCustomer = new Map<string, number>();
for (const inv of open.value) {
byCustomer.set(inv.CustomerID, (byCustomer.get(inv.CustomerID) ?? 0) + inv.Balance);
}
const customersIndex = new Map(
(await client.customers({ top: 200 })).value.map((c) => [c.ID, c.Name]),
);
console.table(
[...byCustomer.entries()].map(([id, total]) => ({
Cliente: customersIndex.get(id) ?? id,
CxC_MXN: total.toFixed(2),
})),
);
// ── 4. Facturas USD a cliente extranjero ──────────────────────────────
step("4. Facturas USD — validar regla sin-IVA + tipo de cambio fijado");
const usd = await client.invoices({ filter: eq("Currency", "USD") });
for (const inv of usd.value) {
const customer = await client.customer(inv.CustomerID);
console.log(
` ${inv.Serie}-${inv.Folio} cliente=${customer.Name} (${customer.Country}) total=$${inv.Total} USD TC=${inv.ExchangeRate ?? "—"} IVA=${inv.Taxes}`,
);
}
// ── 5. Guardrail de escritura ─────────────────────────────────────────
step("5. Intento de escritura en read-only (debe BLOQUEARSE)");
try {
await client.addActivity({
CustomerID: active.value[0]!.ID,
InvoiceID: null,
Type: "note",
Subject: "Prueba desde sandbox",
Notes: null,
CreatedBy: "demo",
});
console.log(" ⚠️ La escritura PASÓ — revisar BIND_MODE.");
} catch (err) {
if (err instanceof BindReadOnlyViolation) {
console.log(` ✅ Guardrail OK: ${err.message}`);
} else {
throw err;
}
}
// ── Resumen ───────────────────────────────────────────────────────────
banner("Stats del cliente");
console.log(client.stats());
// Caso opcional: si se pasa --invoice <guid> bajamos el documento.
const wantInvoice = process.argv.find((a) => a.startsWith("--invoice="));
if (wantInvoice) {
const id = wantInvoice.split("=")[1]!;
step(`Lookup directo: /api/Invoices(${guid(id)})`);
console.log(await client.invoice(id));
}
}
function banner(s: string) {
console.log(`\n${"═".repeat(72)}\n${s}\n${"═".repeat(72)}`);
}
function step(s: string) {
console.log(`\n── ${s}`);
}
function shortId(id: string): string {
return id.slice(0, 8);
}
main().catch((err) => {
console.error("Demo falló:", err);
process.exit(1);
});
@@ -0,0 +1,261 @@
/**
* Datos sintéticos que imitan lo que BIND devolvería para una cuenta
* del tamaño de Balam (45 colaboradores + 5 freelancers, ~50 facturas/mes).
*
* Nada de esto es información real de Balam. Solo cumple con la *forma*
* del payload para que el cliente y los handlers se validen.
*/
import type {
Activity,
Customer,
Invoice,
InvoiceLine,
Payment,
Product,
} from "../../client/types.js";
export const customers: Customer[] = [
{
ID: "11111111-1111-1111-1111-111111111111",
Code: "ACU-001",
Name: "Acuntia México SA de CV",
TaxId: "ACU010203AB1",
Country: "MX",
Email: "facturacion@acuntia.example",
Currency: "MXN",
PaymentTerms: 30,
IsActive: true,
CreatedAt: "2024-01-15T10:00:00Z",
UpdatedAt: "2026-04-01T10:00:00Z",
},
{
ID: "22222222-2222-2222-2222-222222222222",
Code: "CLI-002",
Name: "TechMex Innovaciones SAPI",
TaxId: "TMI150301CD2",
Country: "MX",
Email: "ap@techmex.example",
Currency: "MXN",
PaymentTerms: 45,
IsActive: true,
CreatedAt: "2024-03-20T10:00:00Z",
UpdatedAt: "2026-04-15T10:00:00Z",
},
{
ID: "33333333-3333-3333-3333-333333333333",
Code: "CLI-003",
Name: "Norteamericana Logistics LLC",
TaxId: "98-7654321",
Country: "US",
Email: "billing@norteam.example",
Currency: "USD",
PaymentTerms: 60,
IsActive: true,
CreatedAt: "2025-02-10T10:00:00Z",
UpdatedAt: "2026-05-01T10:00:00Z",
},
{
ID: "44444444-4444-4444-4444-444444444444",
Code: "CLI-004",
Name: "Distribuidora del Golfo SA",
TaxId: "DGO180815EF3",
Country: "MX",
Email: "pagos@dgolfo.example",
Currency: "MXN",
PaymentTerms: 30,
IsActive: true,
CreatedAt: "2025-06-01T10:00:00Z",
UpdatedAt: "2026-05-10T10:00:00Z",
},
{
ID: "55555555-5555-5555-5555-555555555555",
Code: "CLI-005",
Name: "Servicios Estratégicos del Norte",
TaxId: "SEN200401GH4",
Country: "MX",
Email: "tesoreria@sen.example",
Currency: "MXN",
PaymentTerms: 15,
IsActive: false,
CreatedAt: "2025-08-12T10:00:00Z",
UpdatedAt: "2026-03-22T10:00:00Z",
},
];
export const products: Product[] = [
{
ID: "a1111111-1111-1111-1111-111111111111",
Code: "SVC-CONSULT",
Name: "Consultoría estratégica · hora",
UnitPrice: 1800,
Currency: "MXN",
SatCode: "80101504",
IsActive: true,
},
{
ID: "a2222222-2222-2222-2222-222222222222",
Code: "SVC-RECRUIT",
Name: "Búsqueda de talento ejecutivo",
UnitPrice: 45000,
Currency: "MXN",
SatCode: "80111501",
IsActive: true,
},
];
function line(product: Product, qty: number, taxRate: number): InvoiceLine {
const subtotal = round2(qty * product.UnitPrice);
const total = round2(subtotal * (1 + taxRate));
return {
ProductID: product.ID,
Description: product.Name,
Quantity: qty,
UnitPrice: product.UnitPrice,
TaxRate: taxRate,
Subtotal: subtotal,
Total: total,
};
}
export const invoices: Invoice[] = [
// 1) Pagada
{
ID: "f0000001-0000-0000-0000-000000000001",
Folio: "0001",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0001",
CustomerID: customers[1]!.ID,
IssueDate: "2026-03-01T10:00:00Z",
DueDate: "2026-04-15T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 90000,
Taxes: 14400,
Total: 104400,
Balance: 0,
Status: "paid",
Lines: [line(products[1]!, 2, 0.16)],
XmlUrl: "https://api.bind.com.mx/api/Invoices/f0000001/xml",
PdfUrl: "https://api.bind.com.mx/api/Invoices/f0000001/pdf",
},
// 2) Vigente
{
ID: "f0000002-0000-0000-0000-000000000002",
Folio: "0002",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0002",
CustomerID: customers[3]!.ID,
IssueDate: "2026-05-10T10:00:00Z",
DueDate: "2026-06-09T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 36000,
Taxes: 5760,
Total: 41760,
Balance: 41760,
Status: "issued",
Lines: [line(products[0]!, 20, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
// 3) Vencida — caso cobranza
{
ID: "f0000003-0000-0000-0000-000000000003",
Folio: "0003",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0003",
CustomerID: customers[1]!.ID,
IssueDate: "2026-02-15T10:00:00Z",
DueDate: "2026-04-01T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 18000,
Taxes: 2880,
Total: 20880,
Balance: 20880,
Status: "overdue",
Lines: [line(products[0]!, 10, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
// 4) USD a cliente Texas — sin IVA (exportación)
{
ID: "f0000004-0000-0000-0000-000000000004",
Folio: "0004",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0004",
CustomerID: customers[2]!.ID,
IssueDate: "2026-05-20T10:00:00Z",
DueDate: "2026-07-19T10:00:00Z",
Currency: "USD",
ExchangeRate: 17.85,
Subtotal: 12500,
Taxes: 0,
Total: 12500,
Balance: 12500,
Status: "issued",
Lines: [
{
ProductID: products[1]!.ID,
Description: products[1]!.Name,
Quantity: 1,
UnitPrice: 12500,
TaxRate: 0,
Subtotal: 12500,
Total: 12500,
},
],
XmlUrl: null,
PdfUrl: null,
},
// 5) Cliente estratégico — ACUNTIA (no debe recibir recordatorio auto)
{
ID: "f0000005-0000-0000-0000-000000000005",
Folio: "0005",
Serie: "A",
UUID: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAA0005",
CustomerID: customers[0]!.ID,
IssueDate: "2026-04-20T10:00:00Z",
DueDate: "2026-05-20T10:00:00Z",
Currency: "MXN",
ExchangeRate: null,
Subtotal: 54000,
Taxes: 8640,
Total: 62640,
Balance: 62640,
Status: "overdue",
Lines: [line(products[0]!, 30, 0.16)],
XmlUrl: null,
PdfUrl: null,
},
];
export const payments: Payment[] = [
{
ID: "p0000001-0000-0000-0000-000000000001",
InvoiceID: invoices[0]!.ID,
PaymentDate: "2026-04-10T10:00:00Z",
Amount: 104400,
Currency: "MXN",
Method: "transfer",
Reference: "SPEI 7XX9-2026-04-10",
},
];
export const activities: Activity[] = [
{
ID: "ac000001-0000-0000-0000-000000000001",
CustomerID: customers[1]!.ID,
InvoiceID: invoices[2]!.ID,
Type: "reminder",
Subject: "Recordatorio enviado a TechMex (vencida 30 días)",
Notes: "Plantilla cobranza-vencida-30d",
CreatedAt: "2026-04-05T16:00:00Z",
CreatedBy: "balam-collections-bot",
},
];
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
@@ -0,0 +1,187 @@
/**
* Mini-evaluador de filtros OData para el mock.
*
* NO es un parser completo de OData — soporta sólo lo que el cliente del MVP
* genera con los helpers de src/client/odata.ts:
*
* - `Field eq 'value'` / `Field eq guid'...'` / `Field eq 123`
* - `Field ne | gt | lt | ge | le ...`
* - cadenas con AND/OR y paréntesis simples
*
* Suficiente para validar end-to-end que el cliente arma URLs correctas.
*/
type Op = "eq" | "ne" | "gt" | "lt" | "ge" | "le";
type Value = string | number | boolean | null;
interface Comparison {
kind: "cmp";
field: string;
op: Op;
value: Value;
}
interface And {
kind: "and";
left: Node;
right: Node;
}
interface Or {
kind: "or";
left: Node;
right: Node;
}
type Node = Comparison | And | Or;
export function evalFilter<T extends Record<string, unknown>>(
filter: string | undefined,
row: T,
): boolean {
if (!filter) return true;
const node = parse(tokenize(filter));
return run(node, row);
}
// --- tokenizer ---------------------------------------------------------
type Token = { type: "ident" | "op" | "value" | "lparen" | "rparen" | "and" | "or"; v: string };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const c = input[i]!;
if (c === " ") {
i++;
continue;
}
if (c === "(") {
tokens.push({ type: "lparen", v: "(" });
i++;
continue;
}
if (c === ")") {
tokens.push({ type: "rparen", v: ")" });
i++;
continue;
}
if (c === "'") {
let j = i + 1;
let s = "";
while (j < input.length) {
if (input[j] === "'" && input[j + 1] === "'") {
s += "'";
j += 2;
} else if (input[j] === "'") {
break;
} else {
s += input[j];
j++;
}
}
tokens.push({ type: "value", v: s });
i = j + 1;
continue;
}
// guid'...'
if (input.startsWith("guid'", i)) {
const end = input.indexOf("'", i + 5);
tokens.push({ type: "value", v: input.slice(i + 5, end) });
i = end + 1;
continue;
}
// identifier / op / and / or / number / bool
const m = /^[A-Za-z_][A-Za-z0-9_]*|^-?\d+(\.\d+)?/.exec(input.slice(i));
if (!m) throw new Error(`No puedo tokenizar en pos ${i}: ${input.slice(i)}`);
const raw = m[0];
i += raw.length;
if (/^-?\d/.test(raw)) {
tokens.push({ type: "value", v: raw });
continue;
}
const lower = raw.toLowerCase();
if (lower === "and") tokens.push({ type: "and", v: "and" });
else if (lower === "or") tokens.push({ type: "or", v: "or" });
else if (lower === "true" || lower === "false") tokens.push({ type: "value", v: lower });
else if (["eq", "ne", "gt", "lt", "ge", "le"].includes(lower))
tokens.push({ type: "op", v: lower });
else tokens.push({ type: "ident", v: raw });
}
return tokens;
}
// --- parser (precedencia: paréntesis > and > or) -----------------------
function parse(tokens: Token[]): Node {
let pos = 0;
const peek = () => tokens[pos];
const eat = () => tokens[pos++]!;
function parseOr(): Node {
let left = parseAnd();
while (peek()?.type === "or") {
eat();
left = { kind: "or", left, right: parseAnd() };
}
return left;
}
function parseAnd(): Node {
let left = parseAtom();
while (peek()?.type === "and") {
eat();
left = { kind: "and", left, right: parseAtom() };
}
return left;
}
function parseAtom(): Node {
const t = eat();
if (t.type === "lparen") {
const inner = parseOr();
if (eat().type !== "rparen") throw new Error("Falta )");
return inner;
}
if (t.type !== "ident") throw new Error(`Esperaba identificador, recibí ${t.type}`);
const opTok = eat();
if (opTok.type !== "op") throw new Error(`Esperaba operador después de ${t.v}`);
const valTok = eat();
if (valTok.type !== "value") throw new Error(`Esperaba valor después de ${opTok.v}`);
return {
kind: "cmp",
field: t.v,
op: opTok.v as Op,
value: coerce(valTok.v),
};
}
return parseOr();
}
function coerce(raw: string): Value {
if (raw === "true") return true;
if (raw === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
return raw;
}
function run(node: Node, row: Record<string, unknown>): boolean {
if (node.kind === "and") return run(node.left, row) && run(node.right, row);
if (node.kind === "or") return run(node.left, row) || run(node.right, row);
const left = row[node.field];
const right = node.value;
switch (node.op) {
case "eq":
return left == right;
case "ne":
return left != right;
case "gt":
return (left as number) > (right as number);
case "lt":
return (left as number) < (right as number);
case "ge":
return (left as number) >= (right as number);
case "le":
return (left as number) <= (right as number);
default: {
const _exhaustive: never = node.op;
return _exhaustive;
}
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Mock server que imita api.bind.com.mx para desarrollo y pruebas.
*
* - Valida los headers que documenta BIND (Authorization: Bearer + opcionalmente
* Ocp-Apim-Subscription-Key).
* - Soporta $filter, $top, $skip, $orderby, $count sobre las colecciones de seed.
* - Devuelve respuestas en el formato OData v3-ish:
* { value: [...], "odata.count": N }
* - Inyecta latencia y 429 ocasional vía query (?simulate=throttle|slow) para
* ejercitar el cliente.
*
* Por qué un mock y no Postman/Wiremock: el contrato de BIND no es público en
* detalle, así que necesitamos un sandbox que evolucione con lo que vayamos
* descubriendo del API real sin pagar por una herramienta extra.
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { URL } from "node:url";
import { activities, customers, invoices, payments, products } from "./data/seed.js";
import { evalFilter } from "./odata-filter.js";
const PORT = Number(process.env.MOCK_PORT ?? 4010);
interface Collection<T> {
rows: T[];
byIdField?: keyof T;
}
const COLLECTIONS: Record<string, Collection<any>> = {
Customers: { rows: customers, byIdField: "ID" },
Invoices: { rows: invoices, byIdField: "ID" },
Payments: { rows: payments, byIdField: "ID" },
Products: { rows: products, byIdField: "ID" },
Activities: { rows: activities, byIdField: "ID" },
};
const server = createServer(async (req, res) => {
try {
await handle(req, res);
} catch (err) {
sendJson(res, 500, { error: String((err as Error).message) });
}
});
async function handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
if (url.pathname === "/health") return sendJson(res, 200, { ok: true });
// Validación de headers tipo BIND
const auth = req.headers["authorization"];
if (!auth || !String(auth).toLowerCase().startsWith("bearer ")) {
return sendJson(res, 401, {
error: { code: "Unauthorized", message: "Missing Authorization: Bearer <api-key>" },
});
}
// Subscription key es opcional según la doc, pero si la mandan validamos shape.
const sub = req.headers["ocp-apim-subscription-key"];
if (sub !== undefined && String(sub).length < 5) {
return sendJson(res, 401, {
error: { code: "Unauthorized", message: "Subscription key inválida" },
});
}
// Simulación de fallas para ejercitar el cliente
const simulate = url.searchParams.get("simulate");
if (simulate === "throttle") {
res.setHeader("Retry-After", "1");
return sendJson(res, 429, {
error: { code: "TooManyRequests", message: "Cuota diaria excedida (simulado)" },
});
}
if (simulate === "slow") {
await new Promise((r) => setTimeout(r, 1500));
}
// Rutas: /api/{Recurso} y /api/{Recurso}(guid'...')
const m = /^\/api\/([A-Za-z]+)(?:\(guid'([^']+)'\))?\/?$/.exec(url.pathname);
if (!m) return sendJson(res, 404, { error: { code: "NotFound", path: url.pathname } });
const [, resource, id] = m;
const col = COLLECTIONS[resource!];
if (!col) return sendJson(res, 404, { error: { code: "ResourceNotFound", resource } });
if (req.method === "GET" && id) {
const row = col.rows.find((r) => r[col.byIdField!] === id);
if (!row) return sendJson(res, 404, { error: { code: "NotFound", id } });
return sendJson(res, 200, row);
}
if (req.method === "GET") {
const $filter = url.searchParams.get("$filter") ?? undefined;
const $top = parseIntOr(url.searchParams.get("$top"), col.rows.length);
const $skip = parseIntOr(url.searchParams.get("$skip"), 0);
const $orderby = url.searchParams.get("$orderby") ?? undefined;
const $count = url.searchParams.get("$count") === "true";
let rows = col.rows.filter((r) => evalFilter($filter, r));
if ($orderby) rows = applyOrderBy(rows, $orderby);
const totalCount = rows.length;
rows = rows.slice($skip, $skip + $top);
const body: { value: unknown[]; "odata.count"?: number } = { value: rows };
if ($count) body["odata.count"] = totalCount;
return sendJson(res, 200, body);
}
if (req.method === "POST" && resource === "Activities" && !id) {
const payload = await readJson(req);
const created = {
ID: cryptoRandomGuid(),
CreatedAt: new Date().toISOString(),
...payload,
};
activities.push(created as any);
return sendJson(res, 201, created);
}
return sendJson(res, 405, { error: { code: "MethodNotAllowed", method: req.method } });
}
function parseIntOr(raw: string | null, fallback: number): number {
if (raw === null) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
function applyOrderBy<T extends Record<string, unknown>>(rows: T[], orderby: string): T[] {
const [field, dir] = orderby.trim().split(/\s+/);
const sign = dir?.toLowerCase() === "desc" ? -1 : 1;
return [...rows].sort((a, b) => {
const av = a[field!] as any;
const bv = b[field!] as any;
if (av < bv) return -1 * sign;
if (av > bv) return 1 * sign;
return 0;
});
}
async function readJson(req: IncomingMessage): Promise<any> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function cryptoRandomGuid(): string {
// Suficiente para mock. En prod BIND emite sus propios IDs.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
server.listen(PORT, () => {
console.log(`[bind-mock] escuchando en http://localhost:${PORT}`);
console.log("[bind-mock] recursos: /api/Customers /api/Invoices /api/Payments /api/Products /api/Activities");
console.log("[bind-mock] auth requerida: Authorization: Bearer <cualquier-cosa>");
});