/** * Ejemplo de punta a punta contra el SANDBOX de la API de clientes empresa. Node ≥ 22, sin dependencias. Hace lo que * tiene que hacer una integración, en el orden de inicio-rapido-sandbox.md: * 1. GET /v1/entity y GET /v1/account_numbers: la entidad y sus CVU. * 2. Ingreso: POST /v1/simulations/incoming_transfers (simula el aviso de COELSA) y se concilia por consulta periódica * de GET /v1/events (cursor por `sequence`, sin filtro de tipo) + GET /v1/incoming_transfers/{id}. * 3. Egreso: POST /v1/counterparty_validations, POST /v1/outgoing_transfers con Idempotency-Key (201 `reserved`, * 202 `pending` o 422 `rejected`); el reintento con la MISMA clave devuelve la misma respuesta; se sigue el estado * hasta `completed` (el riel simulado la acredita a los ~2 s). * 4. Un egreso cuyo importe termina en …99 queda `submitted` y se resuelve con POST …/rail_result. * 5. GET /v1/simulations/clock. * * Variables de entorno: * FCM_BASE_URL URL base que te dio FCM en el alta (por ejemplo https://empresas-sandbox.fcmpayments.com) * FCM_API_KEY_FILE archivo con tu API key fcm_test_… (o FCM_API_KEY con el valor) * FCM_KEY_ID el ck_… de tu clave pública registrada en FCM * FCM_PRIVATE_KEY_FILE tu clave privada Ed25519 (PEM PKCS#8). Nunca sale de tu sistema ni va a un log * FCM_CURSOR_FILE (opcional) dónde guardar el último `sequence` procesado; si no, arranca de 0 en cada corrida * FCM_CONECTAR (opcional) a dónde conectar en lugar de FCM_BASE_URL; se firma igual sobre * FCM_BASE_URL (la API arma @target-uri con su URL pública, no con el Host) * * Correrlo, con firma-pedidos.ts al lado: `npx tsx ejemplo-sandbox.ts`, o compilado * (`npx esbuild ejemplo-sandbox.ts --bundle --platform=node --format=esm --outfile=ejemplo.mjs && node ejemplo.mjs`). */ import { createPrivateKey, randomUUID, type KeyObject } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { signFcmRequest } from "./firma-pedidos.js"; // Valores mágicos del riel simulado (ver «Inicio rápido (sandbox)»). const ALIAS_VALIDA = "sbx.valida"; const CUIT_TITULAR_VALIDA = "27000000006"; interface Configuracion { readonly baseUrl: string; readonly conectar: string; readonly apiKey: string; readonly keyId: string; readonly privateKey: KeyObject; readonly cursorFile: string | undefined; } interface Lista { readonly data: readonly T[]; readonly has_more: boolean; } interface Entidad { readonly id: string; readonly legal_name: string; readonly status: string; } interface NumeroDeCuenta { readonly id: string; readonly account_id: string; readonly cvu: string; readonly status: string; readonly is_primary: boolean; } interface CreditoEntrante { readonly id: string; readonly account_id: string; readonly amount: number; readonly net_amount: number; readonly payer: { readonly name: string | null } | null; } interface Salida { readonly id: string; readonly status: "pending" | "rejected" | "reserved" | "submitted" | "completed" | "failed"; readonly status_reason: string | null; readonly amount: number; readonly total_debit_amount: number; readonly failure: { readonly code: string } | null; } interface Validacion { readonly id: string; readonly result: string; } interface Evento { readonly id: string; readonly type: string; readonly sequence: number; readonly data: { readonly object: string; readonly id: string }; } interface ListaDeEventos { readonly data: readonly Evento[]; readonly has_more: boolean; } interface Reloj { readonly now: string; readonly offset_seconds: number; } interface ErrorFcm { readonly error?: { readonly code?: string; readonly message?: string; readonly retryable?: boolean }; } interface Respuesta { readonly status: number; readonly replayed: boolean; readonly body: unknown; } const esperar = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); const pesos = (centavos: number): string => `$ ${(centavos / 100).toFixed(2)}`; function leerConfiguracion(env: NodeJS.ProcessEnv): Configuracion { const obligatoria = (nombre: string): string => { const valor = env[nombre]?.trim(); if (valor === undefined || valor === "") throw new Error(`falta ${nombre}`); return valor; }; const baseUrl = obligatoria("FCM_BASE_URL").replace(/\/+$/, ""); const apiKey = (env.FCM_API_KEY ?? readFileSync(obligatoria("FCM_API_KEY_FILE"), "utf8")).trim(); if (!apiKey.startsWith("fcm_test_")) throw new Error("este ejemplo es para el sandbox: la API key tiene que ser fcm_test_…"); return { baseUrl, conectar: (env.FCM_CONECTAR ?? baseUrl).replace(/\/+$/, ""), apiKey, keyId: obligatoria("FCM_KEY_ID"), privateKey: createPrivateKey(readFileSync(obligatoria("FCM_PRIVATE_KEY_FILE"), "utf8")), cursorFile: env.FCM_CURSOR_FILE, }; } /** * Un pedido firmado. Cada intento lleva firma nueva (nonce y `created` nuevos) pero el MISMO cuerpo y la MISMA * Idempotency-Key: un 429, un 5xx reintentable o un corte de red se reintentan así, nunca con otra clave. */ async function pedir(config: Configuracion, metodo: "GET" | "POST", ruta: string, cuerpo?: unknown, idempotencyKey?: string): Promise { const body = cuerpo === undefined ? undefined : Buffer.from(JSON.stringify(cuerpo)); for (let intento = 1; ; intento += 1) { const firmado = signFcmRequest({ method: metodo, targetUri: `${config.baseUrl}${ruta}`, authorization: `Bearer ${config.apiKey}`, privateKey: config.privateKey, keyId: config.keyId, ...(body === undefined ? {} : { body, contentType: "application/json", idempotencyKey: idempotencyKey ?? "" }), }); let respuesta: Response; try { respuesta = await fetch(`${config.conectar}${ruta}`, { method: metodo, headers: firmado.headers, redirect: "manual", ...(body === undefined ? {} : { body }) }); } catch (error) { if (intento >= 5) throw error; await esperar(1_000 * 2 ** (intento - 1)); continue; } const texto = await respuesta.text(); const parseado: unknown = texto === "" ? null : JSON.parse(texto); const error = (parseado as ErrorFcm | null)?.error; // 429 y 5xx reintentables; 409 idempotency_request_in_progress también (el primer intento sigue en curso). const reintentable = respuesta.status === 429 || (respuesta.status >= 500 && error?.retryable !== false) || (respuesta.status === 409 && error?.retryable === true); if (reintentable && intento < 5) { const retryAfter = Number(respuesta.headers.get("retry-after") ?? "0"); await esperar(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 1_000 * 2 ** (intento - 1)); continue; } return { status: respuesta.status, replayed: respuesta.headers.get("idempotent-replayed") === "true", body: parseado }; } } function exigir(respuesta: Respuesta, ...esperados: number[]): T { if (!esperados.includes(respuesta.status)) { const error = (respuesta.body as ErrorFcm | null)?.error; throw new Error(`esperaba HTTP ${esperados.join(" o ")} y llegó ${respuesta.status} ${error?.code ?? ""}: ${error?.message ?? ""}`); } return respuesta.body as T; } function leerCursor(archivo: string | undefined): number { if (archivo === undefined || !existsSync(archivo)) return 0; const valor = Number(readFileSync(archivo, "utf8").trim()); return Number.isSafeInteger(valor) && valor >= 0 ? valor : 0; } /** * Ingreso por consulta periódica (sin webhooks): pide los eventos desde el último `sequence` procesado, en orden, SIN * filtro de tipo (así la secuencia es contigua por entidad y un hueco se detecta). Cada evento se procesa idempotente * por su `evt_` y el cursor se guarda DESPUÉS de procesarlo: en tu sistema, en la misma transacción que el efecto. * Un tipo de evento desconocido se ignora (pueden aparecer tipos nuevos sin cambio de versión). */ async function consumirEventos(config: Configuracion, desde: number, manejar: (evento: Evento) => Promise): Promise { let cursor = desde; for (;;) { const pagina = exigir(await pedir(config, "GET", `/v1/events?after_sequence=${cursor}&limit=100`), 200); for (const evento of pagina.data) { if (cursor !== 0 && evento.sequence !== cursor + 1) { throw new Error(`hueco en la secuencia de eventos: esperaba ${cursor + 1} y llegó ${evento.sequence} (avisale a FCM)`); } await manejar(evento); cursor = evento.sequence; if (config.cursorFile !== undefined) writeFileSync(config.cursorFile, `${cursor}\n`); } if (!pagina.has_more) return cursor; } } /** Sigue una transferencia saliente por GET hasta un estado esperado (lo mismo llega por webhook o por /v1/events). */ async function esperarEstado(config: Configuracion, id: string, estados: readonly Salida["status"][], segundos: number): Promise { for (let i = 0; i < segundos * 2; i += 1) { const salida = exigir(await pedir(config, "GET", `/v1/outgoing_transfers/${id}`), 200); if (estados.includes(salida.status)) return salida; await esperar(500); } throw new Error(`${id} no llegó a ${estados.join("/")} en ${segundos} s`); } async function principal(): Promise { const config = leerConfiguracion(process.env); let cursor = leerCursor(config.cursorFile); // 1. La entidad y sus CVU. const entidad = exigir(await pedir(config, "GET", "/v1/entity"), 200); console.log(`entidad ${entidad.id} (${entidad.legal_name}), estado ${entidad.status}`); const numeros = exigir>(await pedir(config, "GET", "/v1/account_numbers"), 200); const activos = numeros.data.filter((n) => n.status === "active"); const cvu = activos.find((n) => n.is_primary) ?? activos[0]; if (cvu === undefined) throw new Error("la entidad no tiene un CVU activo"); console.log(`CVU ${cvu.cvu} (${cvu.id}) de la cuenta ${cvu.account_id}`); // Ponerse al día: lo anterior ya está conciliado (o se concilia acá, idempotente por evt_). cursor = await consumirEventos(config, cursor, async () => undefined); // 2. Ingreso: un crédito simulado de $ 15.000 recorre el mismo camino que un aviso real de COELSA. const credito = exigir( await pedir(config, "POST", "/v1/simulations/incoming_transfers", { account_number_id: cvu.id, amount: 1_500_000, payer: { name: "PAGADOR DE PRUEBA" }, concept: "VAR" }, randomUUID()), 201, ); console.log(`crédito simulado ${credito.id}: ${pesos(credito.amount)}, neto de comisión ${pesos(credito.net_amount)}`); let conciliado = false; for (let vuelta = 0; vuelta < 15 && !conciliado; vuelta += 1) { cursor = await consumirEventos(config, cursor, async (evento) => { if (evento.type !== "transfer.incoming.posted") { console.log(` evento ${evento.sequence} ${evento.type} ${evento.data.id}`); return; } // El evento es delgado: el detalle (importe, comisión, pagador) se pide aparte. const itr = exigir(await pedir(config, "GET", `/v1/incoming_transfers/${evento.data.id}`), 200); console.log(` evento ${evento.sequence} ${evento.type}: ${itr.id} ${pesos(itr.amount)} de ${itr.payer?.name ?? "?"}`); if (itr.id === credito.id) conciliado = true; }); if (!conciliado) await esperar(2_000); } if (!conciliado) throw new Error(`el crédito ${credito.id} no apareció en /v1/events`); // 3. Egreso: validar el destino, ordenar con Idempotency-Key y seguir el estado. const validacion = exigir( await pedir(config, "POST", "/v1/counterparty_validations", { destination: { type: "alias", value: ALIAS_VALIDA }, expected_cuit: CUIT_TITULAR_VALIDA }, randomUUID()), 201, ); if (validacion.result !== "valid") throw new Error(`la validación de ${ALIAS_VALIDA} dio ${validacion.result}`); const orden = { account_id: credito.account_id, amount: 100_000, currency: "ARS", destination: { type: "alias", value: ALIAS_VALIDA }, expected_counterparty_cuit: CUIT_TITULAR_VALIDA, counterparty_validation_id: validacion.id, external_reference: `ejemplo-${Date.now()}`, }; const clave = randomUUID(); // una por intención de pago: se guarda ANTES de mandar el pedido const creada = await pedir(config, "POST", "/v1/outgoing_transfers", orden, clave); if (creada.status === 422) throw new Error(`rechazada por la autorización: ${(creada.body as ErrorFcm).error?.code ?? "?"}`); const salida = exigir(creada, 201, 202); console.log(`egreso ${salida.id}: HTTP ${creada.status} ${salida.status}, débito total ${pesos(salida.total_debit_amount)}`); const repetida = await pedir(config, "POST", "/v1/outgoing_transfers", orden, clave); if (repetida.status !== creada.status || !repetida.replayed || (repetida.body as Salida).id !== salida.id) { throw new Error("el reintento con la misma Idempotency-Key no devolvió la respuesta guardada"); } console.log(` reintento con la misma clave: HTTP ${repetida.status}, Idempotent-Replayed: true, misma transferencia`); const final = await esperarEstado(config, salida.id, ["completed", "failed"], 30); if (final.status !== "completed") throw new Error(`el egreso terminó ${final.status} (${final.failure?.code ?? "?"})`); console.log(` ${salida.id} completed`); // 4. Un importe que termina en …99 queda submitted hasta que el resultado se inyecta a mano. const enEspera = exigir(await pedir(config, "POST", "/v1/outgoing_transfers", { ...orden, amount: 1_099, counterparty_validation_id: undefined, external_reference: `ejemplo-99-${Date.now()}` }, randomUUID()), 201, 202); await esperarEstado(config, enEspera.id, ["submitted"], 30); const resuelta = exigir(await pedir(config, "POST", `/v1/simulations/outgoing_transfers/${enEspera.id}/rail_result`, { outcome: "completed" }, randomUUID()), 200); if (resuelta.status !== "completed") throw new Error(`rail_result dejó ${enEspera.id} en ${resuelta.status}`); console.log(`egreso …99 ${enEspera.id}: submitted → rail_result → completed`); // 5. El reloj de simulación de la entidad (topes diarios y mensuales, reintentos de webhooks). const reloj = exigir(await pedir(config, "GET", "/v1/simulations/clock"), 200); console.log(`reloj de la entidad: ${reloj.now} (adelantado ${reloj.offset_seconds} s)`); // Los egresos también dejaron sus eventos (reserved, completed): se concilian con el mismo cursor. cursor = await consumirEventos(config, cursor, async (evento) => { console.log(` evento ${evento.sequence} ${evento.type} ${evento.data.id}`); }); console.log(`listo: último sequence procesado ${cursor}`); } principal().catch((error: unknown) => { console.error(`ejemplo-sandbox: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; });