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