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