Protocolo Habilitalo v0.1
Un Protocolo Abierto de Conectores para Integración de Datos Financieros, Operativos y Comerciales
Resumen
Toda empresa que se conecta a un banco, procesa un CSV, recibe un webhook o integra datos de un sistema operativo o comercial enfrenta el mismo problema: transformar datos externos desordenados en registros internos limpios. La respuesta de la industria han sido plataformas intermediarias — Plaid, MX, Yodlee, Salt Edge — que abstraen la fuente pero reemplazan una dependencia por otra. Intercambias lock-in de proveedor por lock-in de agregador, pagas renta por conexión a perpetuidad, y pierdes control sobre el pipeline de datos entre tu fuente y tu sistema.
Construimos Habilitalo para resolver esto nosotros mismos. Comenzó como la capa de integración dentro de Balancealo, la plataforma de observabilidad financiera de Grupo Digitalo. Después de construir tres conectores, un libro de partida doble, un sistema de eventos y dos motores de análisis, nos dimos cuenta de que el patrón era lo suficientemente general como para ser un protocolo.
Este whitepaper describe ese protocolo tal como existe hoy: aproximadamente 5.500 líneas de TypeScript en 24 archivos, corriendo en producción. No es un documento de roadmap. Todo lo descrito aquí ha sido implementado, probado y desplegado. Donde mencionamos trabajo futuro, lo decimos explícitamente.
La integración de datos — financieros, operativos o comerciales — debería ser un protocolo, no una plataforma. Un conector debería ser una declaración: qué recibe, qué emite, cómo se autentica, acompañada de una función de transformación.
El resto (deduplicación, posteo al libro mayor, entrega de eventos, detección de anomalías) es infraestructura que cualquier conector puede usar sin construirla por sí mismo.
El Problema
La integración de datos — financieros, operativos y comerciales — está rota de tres maneras específicas.
1. Fragmentación
Cada institución financiera, sistema operativo o plataforma comercial expone datos de forma diferente. Los bancos chilenos a través de Fintoc usan post_date y sender_account.holder_name anidado. Un CSV de cartola bancaria puede tener Fecha, Monto y Glosa con formato numérico chileno (1.234.567,89). Otro CSV usa Date, Amount y Description con formato estadounidense (1,234,567.89). Una entrada manual desde un formulario tiene otra forma completamente distinta.
Cada fuente requiere su propia lógica de parsing, su propio manejo de fechas, su propia normalización de montos. Los equipos construyen esto desde cero cada vez. El código de parsing es sencillo; el problema es que se enreda con la lógica de negocio, haciendo imposible su reutilización.
2. Lock-In
Las plataformas de agregación que prometen resolver la fragmentación crean su propia dependencia. Tu pipeline de datos se convierte en: Banco → Agregador → Tu App. Cambiar de agregador significa reescribir tu capa de ingestión. El agregador controla el formato de datos, el mecanismo de entrega, el calendario de sincronización y el manejo de errores. No puedes inspeccionar ni modificar el pipeline entre el banco y tu base de datos.
3. Extracción de Renta
Los agregadores cobran por conexión, por cuenta, por mes. Esto tiene sentido económico para ellos pero crea incentivos perversos. Una fintech que atiende pequeñas empresas en América Latina no puede pagar USD 0,50/conexión/mes cuando su cliente promedio tiene 3 cuentas y paga USD 5/mes en total. El agregador se lleva el 30% de los ingresos por hacer lo que equivale a una llamada HTTP y una transformación de datos.
Qué es Habilitalo
Habilitalo es un protocolo abierto de Conectores que separa el qué de la integración (qué datos entran, qué eventos salen) del cómo (el pipeline que los procesa). Aunque nació en el contexto financiero, el protocolo es agnóstico al dominio: cualquier fuente de datos — financiera, operativa o comercial — puede expresarse como un conector.
Un Conector es una declaración con una función de transformación. La declaración le dice al sistema qué transporte usa el conector (webhook, carga de archivo, polling de API, entrada manual), qué eventos puede recibir de la fuente externa, qué eventos canónicos emite al sistema, y qué autenticación requiere.
La función de transformación toma datos crudos de la fuente y un objeto de contexto, y retorna un CanonicalEvent — un sobre uniforme que el resto del sistema puede procesar sin saber nada sobre la fuente.
Todo lo que está downstream del Conector — staging, deduplicación, posteo al libro mayor, entrega de eventos, detección de anomalías — es infraestructura compartida. Construir un nuevo conector significa escribir una función de transformación y un manifiesto JSON. El pipeline se encarga del resto.
Principios de Diseño
- Declaración sobre configuración. Un manifiesto de conector (
habilitalo.connector.json) declara capacidades. Sin código de configuración imperativo. - Aislamiento del transform. La función de transformación es el único código que escribe el autor del conector. Recibe datos crudos y retorna un evento canónico. Los efectos secundarios ocurren en el pipeline.
- Entrega al menos una vez. Los eventos se persisten antes de intentar la entrega. El sistema tolera entregas duplicadas; se espera que los consumidores sean idempotentes.
- Deduplicación basada en hash. Cada entrada recibe un hash SHA-256 determinístico. Los mismos datos ingestados dos veces producen el mismo hash y se descartan silenciosamente.
- Corrección de partida doble. Cada movimiento financiero produce un asiento contable balanceado. Débitos igualan créditos, siempre.
- Eventos fire-and-forget. La emisión de eventos nunca bloquea el pipeline. Los eventos son un mecanismo de notificación, no un mecanismo de flujo de control.
Conceptos Clave
Conector
La interfaz HabilitaloConnector es la abstracción central. Cada fuente de datos — una API bancaria, una carga de archivo, un formulario manual — se representa como un Conector.
interface HabilitaloConnector {
/** Identificador único del conector (ej. "fintoc-v1") */
id: string
/** Versión semver de este conector */
version: string
/** Lo que este conector puede recibir */
inbound: InboundDeclaration
/** Qué eventos canónicos emite este conector */
outbound: OutboundDeclaration
/** Requisitos de autenticación */
auth: AuthDeclaration
/** Función transform: datos crudos -> CanonicalEvent */
transform: HabilitaloTransform
}
La InboundDeclaration especifica el mecanismo de transporte y los tipos de evento que el conector maneja desde la fuente:
interface InboundDeclaration {
/** Mecanismo de transporte: webhook, file, manual, api_poll */
transport: 'webhook' | 'file' | 'manual' | 'api_poll'
/** Tipos de evento que este conector puede recibir de la fuente */
events: string[]
}
La OutboundDeclaration especifica qué tipos de eventos canónicos produce este conector:
interface OutboundDeclaration {
/** Tipos de eventos canónicos que este conector emite */
events: string[]
}
La AuthDeclaration le dice al sistema qué credenciales se requieren:
interface AuthDeclaration {
/** Tipo de autenticación requerido por la fuente */
type: 'api_key' | 'oauth2' | 'link_token' | 'none'
/** Campos de credencial requeridos (ej. ["apiKey", "secretKey"]) */
requiredFields?: string[]
}
La función de transformación recibe datos crudos y un objeto de contexto:
type HabilitaloTransform = (
raw: unknown,
context: ConnectorContext
) => CanonicalEvent
interface ConnectorContext {
/** ID de organización/tenant */
tenantId: string
/** ID del data feed (si aplica) */
feedId?: string
/** Configuración del conector (específica por fuente) */
config: Record<string, unknown>
/** Trace ID para tracing distribuido */
traceId: string
}
Evento Canónico
Cada conector emite eventos en una única forma canónica. Este es el sobre universal que fluye a través de todo el sistema — desde el conector al pipeline, al webhook, al motor de análisis.
interface CanonicalEvent {
/** Identificador único del evento (UUIDv4) */
id: string
/** Timestamp ISO8601 de cuándo se emitió el evento */
timestamp: string
/** Sistema que originó el evento (ej. "habilitalo") */
source: string
/** Tipo de evento en notación de punto (ej. "bank.movement.created") */
type: string
/** Versión del esquema de este tipo de evento (semver) */
version: string
/** Payload específico del evento -- forma depende de `type` */
payload: Record<string, unknown>
/** Metadata de ruteo y tracing */
meta: {
connector: string
tenant: string
trace_id: string
}
}
El helper createCanonicalEvent provee valores por defecto sensatos:
function createCanonicalEvent(
params: Pick<CanonicalEvent, 'type' | 'payload' | 'meta'> & {
id?: string
version?: string
source?: string
}
): CanonicalEvent {
return {
id: params.id ?? crypto.randomUUID(),
timestamp: new Date().toISOString(),
source: params.source ?? 'habilitalo',
type: params.type,
version: params.version ?? '1.0.0',
payload: params.payload,
meta: params.meta,
}
}
Registry
El ConnectorRegistry es el catálogo en tiempo de ejecución de todos los conectores disponibles. Sirve dos propósitos: es el mecanismo de búsqueda para el pipeline (dado un tipo de feed, encontrar el conector correcto) y es el puente de compatibilidad entre la nueva capa de protocolo y el sistema existente de SourceAdapter.
const ConnectorRegistry = {
register(connector: HabilitaloConnector, adapter?: SourceAdapter): void
get(id: string): HabilitaloConnector | undefined
list(): HabilitaloConnector[]
has(id: string): boolean
getSourceAdapter: getSourceAdapter // Puente legacy
listSourceAdapters: listSourceAdapters // Puente legacy
}
La función bridgeAdapterToConnector envuelve un SourceAdapter existente en la interfaz HabilitaloConnector sin modificar el código del adapter:
function bridgeAdapterToConnector(
adapter: SourceAdapter,
manifest: {
id: string
version: string
inbound: HabilitaloConnector['inbound']
outbound: HabilitaloConnector['outbound']
auth: HabilitaloConnector['auth']
}
): HabilitaloConnector
El Pipeline de 3 Etapas
El pipeline es el núcleo de Habilitalo. Toma datos crudos de cualquier fuente y produce asientos contables balanceados, eventos emitidos y resultados de análisis. Las tres etapas son secuenciales y desacopladas — cada etapa lee y escribe a la base de datos, de modo que una falla en una etapa no corrompe las demás.
Etapa 1 Etapa 2 Etapa 3
(Ingestar) (Procesar) (Analizar)
+------------+ +--------------+ +------------+
Fuente Externa | Adapter | ---> | RawEntry | --> | JournalEntry|
(banco, CSV, | Transform | | (staging) | | (libro) |
formulario) | Validar | | Deduplicar | | Balanceado |
| Hash | | Partida doble| | débito/créd.|
+------------+ +--------------+ +------------+
|
+-----+------+
| |
[Eventos] [Análisis]
| |
Webhooks Anomalías
Patrones
Etapa 1: Ingestión
La etapa de ingestión transforma datos crudos de la fuente en registros RawEntry en la tabla de staging. Aquí es donde ocurre el parsing específico de cada fuente: formatos de fecha, formatos de monto, mapeo de campos, detección de moneda.
Entrada: Datos crudos de la fuente (movimientos Fintoc, filas CSV, datos de formulario manual).
Proceso:
- El
SourceAdapterapropiado transforma los datos de la fuente enRawEntryInput[]. - Cada entrada se valida: campos requeridos, límites de monto, sanidad de fecha, validez de moneda.
- Se calcula un hash SHA-256 desde campos normalizados:
date|amount|accountId|description|reference. - El hash se compara contra entradas existentes en la misma organización.
- Si es duplicado: se descarta silenciosamente (o se usa sufijo de secuencia para colisiones legítimas).
- Si es nuevo: se crea un
RawEntrycon estadopending.
Salida: IngestResult con conteos de entradas staged, duplicadas y con error.
interface IngestResult {
staged: number
duplicates: number
errors: number
entries: Array<{
id?: string
status: 'staged' | 'duplicate' | 'error'
hash?: string
error?: string
}>
}
El RawEntryInput es el formato intermedio universal que produce cada adapter:
interface RawEntryInput {
dataFeedId: string
organizationId: string
transactionDate: Date
postDate?: Date | null
amount: number
currency?: Currency
description: string
counterparty?: string | null
reference?: string | null
sourceId?: string | null
rawData: Record<string, unknown>
}
Decisiones clave de diseño en la etapa de ingestión:
- Batch vs. individual. Para lotes de menos de 100 entradas, usamos
createManycon hashes pre-calculados. Para lotes más grandes, hacemos procesamiento individual para evitar transacciones largas. - Preservación de datos crudos. Los datos originales se almacenan en
rawDatacomo columna JSON, permitiendo re-procesamiento o auditoría sin consultar la fuente nuevamente. - Estadísticas de feed. Después de una ingestión exitosa, el registro
DataFeedse actualiza con incremento detotalEntriesy timestamplastSyncAt.
Etapa 2: Procesamiento
El procesador toma registros RawEntry pendientes y los convierte en registros JournalEntry de partida doble en el libro mayor.
Entrada: Registros RawEntry con estado pending.
Proceso:
- Obtener entradas pendientes ordenadas por fecha de transacción.
- Para cada entrada, marcar como
processing(previene procesamiento concurrente). - Verificación de race condition: confirmar que ninguna otra entrada con el mismo hash ya esté
processed. - Determinar dirección de la transacción por signo del monto (positivo = ingreso, negativo = gasto).
- Crear un
JournalEntryconJournalLine[]débito/crédito balanceados. - Marcar
RawEntrycomoprocessedcon referencia alJournalEntry. - Fire-and-forget: emitir evento
bank.movement.created.
Salida: ProcessorResult con conteos y detalles por entrada.
interface ProcessorResult {
processed: number
duplicates: number
errors: number
details: Array<{
rawEntryId: string
status: 'processed' | 'duplicate' | 'error'
journalEntryId?: string
error?: string
}>
}
Manejo de errores: Si el procesamiento falla para una entrada, se marca como error con el mensaje de error almacenado. Las entradas fallidas pueden re-procesarse luego vía reprocessFailedEntries, que resetea su estado a pending y ejecuta el procesador de nuevo.
Etapa 3: Análisis
La etapa de análisis corre asincrónicamente después del posteo al libro mayor. Examina los asientos contables buscando dos clases de patrones:
- Anomalías: Transacciones que se desvían significativamente de las normas históricas.
- Recurrencias: Patrones repetitivos (suscripciones, salario, arriendo, etc.).
Ambos motores leen de las tablas JournalEntry y JournalLine y escriben sus resultados en tablas dedicadas (Anomaly, RecurringPattern, RecurringOccurrence).
Deduplicación
La deduplicación es crítica para un sistema que ingesta datos de múltiples fuentes en calendarios superpuestos. Fintoc puede entregar el mismo movimiento en dos sincronizaciones consecutivas. Un usuario puede subir el mismo CSV dos veces. El sistema debe absorber duplicados silenciosamente sin crear entradas dobles en el libro mayor.
Cálculo de Hash
Cada entrada recibe un hash SHA-256 calculado a partir de cinco campos normalizados:
SHA-256( date | amount | accountId | description | reference )
Reglas de normalización:
- Fecha: Convertida a
YYYY-MM-DD(componente de hora eliminado). - Monto: Valor absoluto con exactamente 2 decimales.
- AccountId: Usado tal cual (UUID).
- Descripción: En minúsculas, acentos removidos (normalización NFD), caracteres especiales eliminados, espacios múltiples colapsados.
- Referencia: Misma normalización que descripción, incluida solo si está presente.
function calculateEntryHash(entry: HashableEntry): HashResult {
const normalizedFields = {
date: normalizeDate(entry.transactionDate),
amount: normalizeAmount(entry.amount),
accountId: entry.accountId,
description: normalizeString(entry.description),
reference: entry.reference ? normalizeString(entry.reference) : '',
}
const parts = [
normalizedFields.date,
normalizedFields.amount,
normalizedFields.accountId,
normalizedFields.description,
]
if (normalizedFields.reference) {
parts.push(normalizedFields.reference)
}
const hashInput = parts.join('|')
const hash = createHash('sha256').update(hashInput).digest('hex')
return { hash, normalizedFields }
}
Manejo de Colisiones
Las colisiones legítimas ocurren cuando una persona hace dos compras idénticas el mismo día. El sistema las distingue verificando sourceId — si dos entradas tienen el mismo hash pero identificadores de fuente diferentes, se tratan como transacciones distintas.
Para colisiones legítimas, se agrega un sufijo de secuencia:
function calculateEntryHashWithSequence(
entry: HashableEntry,
sequence: number
): string {
const { hash: baseHash } = calculateEntryHash(entry)
if (sequence === 0) return baseHash
return createHash('sha256')
.update(`${baseHash}|seq:${sequence}`)
.digest('hex')
}
El sistema intenta hasta 10 números de secuencia antes de desistir y marcar la entrada como duplicada.
Restricción de Base de Datos
El hash de deduplicación se enforce a nivel de base de datos con una restricción unique sobre (organizationId, entryHash). Esto provee una red de seguridad contra race conditions. La ruta de ingestión en bulk usa createMany con skipDuplicates: true como guarda adicional.
Libro de Partida Doble
Cada movimiento financiero en Habilitalo se registra como un JournalEntry con registros JournalLine[] balanceados. Esto es contabilidad de partida doble propiamente tal: por cada débito, hay un crédito igual, por moneda.
Modelo de Datos
JournalEntry
id: UUID
organizationId: UUID
entryNumber: auto-incremento por org
description: string
transactionDate: Date
postDate: Date (por defecto la hora de creación)
status: 'draft' | 'posted' | 'voided'
sourceType: DataFeedType (fintoc, csv_import, manual, etc.)
sourceRef: string (referencia al RawEntry u otra fuente)
lines: JournalLine[]
JournalLine
id: UUID
journalEntryId: UUID
accountId: UUID
entryType: 'debit' | 'credit'
amount: Decimal
currency: Currency
description: string
counterparty: string
categoryId: UUID (opcional)
ownerId: UUID (opcional)
Validación de Balance
Antes de crear cualquier JournalEntry, el sistema valida que los débitos igualen los créditos para cada moneda:
function validateJournalLinesBalance(
lines: Array<{ entryType: 'debit' | 'credit'; amount: number; currency: string }>
): ValidationResult {
const byCurrency = new Map<string, { debits: number; credits: number }>()
for (const line of lines) {
const curr = byCurrency.get(line.currency) || { debits: 0, credits: 0 }
if (line.entryType === 'debit') {
curr.debits += line.amount
} else {
curr.credits += line.amount
}
byCurrency.set(line.currency, curr)
}
for (const [currency, { debits, credits }] of byCurrency) {
const diff = Math.abs(debits - credits)
if (diff > 0.01) {
errors.push(
`Líneas en ${currency} no están balanceadas: débitos=${debits}, créditos=${credits}`
)
}
}
return { valid: errors.length === 0, errors, warnings }
}
Naturaleza de Cuenta
Las cuentas tienen una naturaleza que determina cómo se calcula su saldo:
debit_normal(activos, gastos): Saldo = SUMA(débitos) - SUMA(créditos).credit_normal(pasivos, patrimonio, ingresos): Saldo = SUMA(créditos) - SUMA(débitos).
async function calculateAccountBalance(
accountId: string,
asOfDate?: Date,
includeVoided = false
): Promise<AccountBalance> {
// ... obtener cuenta y agregar líneas ...
const balance =
account.accountNature === 'debit_normal'
? debits - credits
: credits - debits
return {
accountId: account.id,
accountName: account.name,
accountNature: account.accountNature,
totalDebits: debits,
totalCredits: credits,
balance,
currency: account.currency,
}
}
Tipos de Transacción
El libro mayor soporta tres tipos simples de transacción:
- Ingreso: DÉBITO a la cuenta bancaria (aumenta activo), CRÉDITO a la contra de ingresos.
- Gasto: DÉBITO a la contra de gastos, CRÉDITO a la cuenta bancaria (disminuye activo).
- Transferencia: DÉBITO a la cuenta destino, CRÉDITO a la cuenta origen.
async function createSimpleEntry(
input: SimpleTransactionInput & { organizationId: string }
): Promise<JournalEntryWithLines> {
switch (input.type) {
case 'income':
return createIncomeEntry(input)
case 'expense':
return createExpenseEntry(input)
case 'transfer':
return createTransferEntry(input)
}
}
Anulación/Reversión
Los asientos contables pueden ser anulados pero nunca eliminados. Una entrada anulada retiene sus líneas y metadata pero se excluye de los cálculos de saldo (a menos que includeVoided se establezca explícitamente). La operación de anulación registra una razón y timestamp para propósitos de auditoría.
Conectores Fundacionales
Habilitalo viene con tres conectores que cubren los escenarios de ingestión más comunes en fintech latinoamericana. El protocolo soporta conectores de cualquier dominio — financiero, operativo o comercial — estos tres fundacionales demuestran el patrón con datos financieros.
Fintoc (Open Banking Chile)
ID del Conector: fintoc-v1 Transporte: webhook Auth: link_token Emite: bank.movement.created
Fintoc provee acceso Open Banking a instituciones financieras chilenas. El conector transforma objetos FintocMovement en registros RawEntryInput.
Mapeo de campos
| Campo Fintoc | Campo RawEntryInput | Notas |
|---|---|---|
post_date | postDate | String fecha ISO |
transaction_date | transactionDate | Fallback a post_date |
amount | amount | Positivo = ingreso, negativo = gasto |
currency | currency | Mapeado: CLP, USD, EUR, UF |
description + comment | description | Concatenados con separador |
sender_account.holder_name | counterparty | Para ingresos (monto > 0) |
recipient_account.holder_name | counterparty | Para gastos (monto < 0) |
reference_id | reference | Usado en cálculo de hash |
id | sourceId | ID de movimiento Fintoc para dedup |
Estrategia de sincronización incremental
La integración con Fintoc usa un enfoque de sincronización por chunks:
- Ventana de 24 meses: Sincroniza hasta 2 años de datos históricos.
- 3 meses por chunk: Cada invocación procesa 3 meses de movimientos.
- Cuentas en paralelo: Todas las cuentas dentro de un link se sincronizan en paralelo.
- Integración con pipeline: Los movimientos fluyen por el pipeline completo.
- Reanudable: El progreso de sincronización se persiste como JSON en el registro
FintocLink.
Manifiesto del conector
{
"id": "fintoc-v1",
"name": "Fintoc Open Banking",
"version": "1.0.0",
"category": "financial",
"tags": ["banking", "chile", "latam"],
"inbound": {
"transport": "webhook",
"events": ["payment.received"]
},
"outbound": {
"events": ["bank.movement.created"]
},
"auth": {
"type": "link_token",
"requiredFields": ["linkToken"]
},
"habilitalo": "0.1"
}
CSV (Cartolas Bancarias)
ID del Conector: csv-v1 Transporte: file Auth: none Emite: bank.movement.created
El conector CSV parsea archivos de cartola bancaria con detección inteligente de formato.
Detección de formato de monto
| Formato | Miles | Decimal | Ejemplo |
|---|---|---|---|
| Chileno | . (punto) | , (coma) | 1.234.567,89 |
| US/Internacional | , (coma) | . (punto) | 1,234,567.89 |
Auto-detección de columnas
| Campo | Nombres de columna de fallback |
|---|---|
| Fecha | fecha, date, transaction_date, transactiondate |
| Monto | monto, amount, valor, value, importe |
| Descripción | descripcion, description, detalle, glosa, concepto |
| Contraparte | contraparte, counterparty, comercio, merchant |
| Referencia | referencia, reference, ref, numero |
{
"id": "csv-v1",
"name": "CSV File Import",
"version": "1.0.0",
"category": "financial",
"tags": ["csv", "file", "import", "bank-statement"],
"inbound": { "transport": "file", "events": ["file.uploaded"] },
"outbound": { "events": ["bank.movement.created"] },
"auth": { "type": "none" },
"habilitalo": "0.1"
}
Manual (Formularios Estructurados)
ID del Conector: manual-v1 Transporte: manual Auth: none Emite: bank.movement.created
El conector Manual es un adapter passthrough para datos estructurados enviados a través de formularios o llamadas API. Valida y normaliza los datos pero no realiza parsing. Este conector existe para asegurar que las transacciones ingresadas manualmente fluyan por el mismo pipeline que las fuentes automatizadas.
{
"id": "manual-v1",
"name": "Manual Entry",
"version": "1.0.0",
"category": "financial",
"tags": ["manual", "entry", "form"],
"inbound": { "transport": "manual", "events": ["entry.submitted"] },
"outbound": { "events": ["bank.movement.created"] },
"auth": { "type": "none" },
"habilitalo": "0.1"
}
Sistema de Eventos
Habilitalo emite eventos vía webhooks cuando ocurren cosas significativas en el pipeline. Los eventos son el mecanismo por el cual los servicios downstream aprenden sobre la actividad financiera.
Arquitectura
El sistema de eventos se construye sobre tres tablas de base de datos:
WebhookEndpoint: Una URL registrada que recibe eventos. Tiene tipos de eventos suscritos, un secreto para firma HMAC, y seguimiento de fallas.WebhookDelivery: Un registro de cada intento de entrega. Persistido antes del primer intento (garantía de al-menos-una-vez).- El emisor de eventos: La función
emit()que orquesta la entrega.
Semántica de Entrega
Al-menos-una-vez: Un registro WebhookDelivery se crea en la base de datos antes de la primera solicitud HTTP.
Fire-and-forget: La función emit() nunca lanza una excepción. Todos los errores se capturan y loguean.
Reintento con backoff exponencial: Las entregas fallidas se reintentan hasta 3 veces:
Intento 1: inmediato
Intento 2: después de 1s (1000ms × 4^0)
Intento 3: después de 4s (1000ms × 4^1)
(desistir después de 3 fallas)
Errores no reintentables: Respuestas HTTP 4xx (excepto 429 Too Many Requests) no se reintentan.
Firma HMAC-SHA256
Cada entrega de webhook se firma con el secreto del endpoint usando HMAC-SHA256:
const signature = createHmac('sha256', endpoint.secretHash)
.update(payloadString)
.digest('hex')
// Headers enviados:
// Content-Type: application/json
// X-Habilitalo-Event: bank.movement.created
// X-Habilitalo-Signature: <HMAC codificado en hex>
Filtrado de Eventos
Cada WebhookEndpoint tiene un array events que especifica qué tipos de eventos quiere recibir. El wildcard * suscribe a todos los eventos:
const subscribedEndpoints = endpoints.filter((ep) => {
const events = ep.events as string[]
return Array.isArray(events) && (
events.includes(eventType) || events.includes('*')
)
})
Tipos de Evento
| Tipo de Evento | Disparador | Payload |
|---|---|---|
bank.movement.created | El procesador crea un JournalEntry desde un RawEntry | Detalles del movimiento, cuenta, monto, contraparte |
analysis.anomaly.detected | El detector de anomalías marca una transacción | Tipo de anomalía, severidad, Z-score, esperado vs. detectado |
analysis.recurrence.detected | El detector de recurrencia identifica un patrón | Nombre del patrón, frecuencia, monto, confianza |
ledger.entry.created | Un JournalEntry se postea al libro mayor | Asiento contable completo con todas las líneas |
API de Gestión de Webhooks
Los endpoints de webhook se gestionan a través de rutas REST API en /api/habilitalo/webhooks:
POST /api/habilitalo/webhooks— Crear un nuevo endpoint.GET /api/habilitalo/webhooks?organizationId=...— Listar endpoints activos.GET /api/habilitalo/webhooks/:id/deliveries— Ver historial de entregas.
Motores de Análisis
Habilitalo incluye dos motores de análisis que corren sobre datos del libro mayor, diseñados para detectar insights que serían difíciles de identificar manualmente.
Detección de Anomalías
El detector de anomalías usa análisis de Z-score sobre estadísticas de transacciones por categoría para marcar outliers. Detecta tres tipos de anomalías:
1. Monto Inusual (unusual_amount)
Para cada categoría de transacción, el sistema mantiene estadísticas corrientes calculadas desde líneas de journal en el período de lookback (por defecto: 90 días, mínimo 10 muestras).
Z = |monto - media_categoría| / desviación_estándar_categoría
| Severidad | Umbral Z-score por Defecto |
|---|---|
| Baja | 2.0 |
| Media | 2.5 |
| Alta | 3.0 |
| Crítica | 4.0 |
Niveles de sensibilidad ajustan todos los umbrales por un multiplicador:
| Sensibilidad | Multiplicador | Efecto |
|---|---|---|
| Baja | 1.3x | Menos alertas, solo outliers extremos |
| Media | 1.0x | Comportamiento por defecto |
| Alta | 0.7x | Más alertas, captura desviaciones menores |
2. Contraparte Nueva (new_counterparty)
Marca la primera transacción con una contraparte no vista previamente cuando el monto supera un umbral configurable (por defecto: CLP 100.000). La severidad escala con el monto:
baja: Monto ≥ umbralmedia: Monto ≥ 2x umbralalta: Monto ≥ 5x umbral
3. Sospecha de Duplicado (duplicate_suspect)
Encuentra transacciones con monto y descripción normalizada idénticos dentro de una ventana de 3 días.
Configuración
interface AnomalyConfig {
lowThreshold: number // Por defecto: 2.0
mediumThreshold: number // Por defecto: 2.5
highThreshold: number // Por defecto: 3.0
criticalThreshold: number // Por defecto: 4.0
minSampleSize: number // Por defecto: 10
lookbackDays: number // Por defecto: 90
newCounterpartyAmountThreshold: number // Por defecto: 100000 (CLP)
}
Detección de Recurrencia
El detector de recurrencia identifica patrones de transacciones repetitivas: suscripciones, depósitos de salario, pagos de arriendo, cuentas de servicios básicos.
Algoritmo
- Agrupar transacciones por descripción normalizada y bucket de monto (bucketing logarítmico, 15% de varianza).
- Filtrar grupos con menos del mínimo de ocurrencias (por defecto: 3).
- Calcular intervalos entre transacciones consecutivas en cada grupo.
- Verificar regularidad. Si la desviación estándar de intervalos excede la varianza máxima (por defecto: 5 días), el grupo no es suficientemente regular.
- Detectar frecuencia desde el intervalo promedio:
| Intervalo Promedio | Frecuencia |
|---|---|
| 1–2 días | diaria |
| 5–9 días | semanal |
| 12–18 días | quincenal |
| 25–35 días | mensual |
| 85–100 días | trimestral |
| 350–380 días | anual |
- Calcular confianza como score ponderado:
confianza = score_regularidad × 0.5
+ score_ocurrencias × 0.3
+ score_consistencia_monto × 0.2
Donde:
score_regularidad = 1 - (stddev_intervalos / promedio_intervalos)
score_ocurrencias = min(1, count_ocurrencias / 12)
score_consistencia = 1 - (stddev_monto / promedio_monto)
Los patrones con confianza bajo 0.7 se descartan.
- Predecir próxima ocurrencia sumando el intervalo promedio a la fecha de última ocurrencia.
Configuración
interface RecurrenceConfig {
minOccurrences: number // Por defecto: 3
maxIntervalVariance: number // Por defecto: 5 días
amountVariancePercent: number // Por defecto: 15%
lookbackDays: number // Por defecto: 180
minConfidence: number // Por defecto: 0.7
}
Ciclo de vida de patrones
Los patrones detectados se almacenan como registros RecurringPattern con estado active. Cada transacción que coincide crea un registro RecurringOccurrence. El sistema también rastrea ocurrencias perdidas — habilitando alertas proactivas como "Tu suscripción de Netflix de $12.990 CLP se esperaba el 5 de marzo pero no ha llegado."
Posición en el Stack
Habilitalo es la capa de integración y observación del ecosistema Digitalo. Se ubica en la frontera entre sistemas externos y el mesh de servicios interno.
+------------------------------------------------------------------+
| MUNDO EXTERNO |
| Bancos (Fintoc) | CSVs | Manual | Futuro: SAT, SII, etc. |
+--------+---------+--------+----------+---------------------------+
| | |
v v v
+------------------------------------------------------------------+
| HABILITALO |
| Conectores -> Pipeline -> Libro -> Eventos -> Análisis |
| (protocolo) (ingestar) (partida (webhooks) (anomalía, |
| (procesar) doble) recurrencia)
+--------+---------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| SERVICIALO |
| Service mesh, orquestación, comunicación inter-servicios |
+--------+---------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| COMPENSALO |
| Pagos, desembolsos, cobranzas |
+--------+---------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| COORDINALO |
| Agendamiento, agenda, citas |
+--------+---------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| ACUMULALO |
| Wallet, fidelización, puntos |
+------------------------------------------------------------------+
Los datos fluyen hacia adentro a través de Habilitalo: las fuentes externas empujan o son polleadas, los conectores transforman los datos, el pipeline los procesa, y los eventos notifican a los servicios downstream. Compensalo escucha bank.movement.created para conciliar pagos. Coordinalo escucha analysis.recurrence.detected para programar recordatorios.
Habilitalo es extraíble. Corre como un módulo dentro de Balancealo hoy, pero el grafo de dependencias está diseñado para su futura extracción como servicio independiente.
Implementación Técnica
Stack Tecnológico
- Runtime: Next.js 15+ sobre Node.js
- Lenguaje: TypeScript 5 (modo estricto)
- Base de datos: PostgreSQL vía Supabase
- ORM: Prisma con migraciones
- Criptografía: Módulo
cryptonativo de Node.js (SHA-256, HMAC-SHA256) - Multi-tenancy: Basado en organizaciones con aislamiento de datos a nivel de fila
Estructura de Archivos
src/lib/habilitalo/ (~5.500 LOC)
|-- index.ts Exports centrales
|-- README.md Documentación del módulo
|
|-- protocol/ Protocolo de conectores
| |-- connector.ts Interfaz HabilitaloConnector
| |-- canonical-event.ts Esquema CanonicalEvent + helper
| |-- registry.ts ConnectorRegistry + bridge
| +-- index.ts Exports del protocolo
|
|-- events/ Sistema de eventos
| |-- HabilitaloEventEmitter.ts Emisor core (HMAC, retry)
| |-- emitBankMovementCreated.ts Constructor de evento
| |-- index.ts Exports de eventos
| +-- schemas/ Definiciones de tipos de evento
| |-- BankMovementCreated.ts bank.movement.created
| |-- AnomalyDetected.ts analysis.anomaly.detected
| |-- RecurrenceDetected.ts analysis.recurrence.detected
| +-- LedgerEntryCreated.ts ledger.entry.created
|
|-- feeds/ Pipeline
| |-- index.ts Exports de feeds
| |-- types.ts Interfaces core
| |-- hash.ts Motor de dedup SHA-256
| |
| |-- pipeline/ ETL de 3 etapas
| | |-- ingest.ts Etapa 1: fuente -> RawEntry
| | |-- validate.ts Validación de entrada + balance
| | +-- processor.ts Etapa 2: RawEntry -> JournalEntry
| |
| |-- sources/ Implementaciones de conectores
| | |-- base.ts Interfaz SourceAdapter + registry
| | |-- fintoc.ts Adapter Fintoc
| | |-- fintoc/habilitalo.connector.json Manifiesto Fintoc
| | |-- csv.ts Adapter CSV
| | |-- csv/habilitalo.connector.json Manifiesto CSV
| | |-- manual.ts Adapter Manual
| | +-- manual/habilitalo.connector.json Manifiesto Manual
| |
| |-- ledger/ Contabilidad partida doble
| | |-- journal.ts CRUD, anulación, consultas
| | +-- balance.ts Balance por naturaleza de cuenta
| |
| +-- analysis/ Motores de análisis
| |-- types.ts Config + tipos de resultado
| |-- anomaly-detector.ts Detección de anomalías Z-score
| |-- recurrence-detector.ts Detección de patrones
| +-- index.ts Exports de análisis
|
+-- integrations/ Orquestadores de terceros
+-- fintoc/
+-- sync-incremental.ts Sync incremental 24 meses
Superficie de API
- Gestión de webhooks:
POST/GET /api/habilitalo/webhooks - Inspección de entregas:
GET /api/habilitalo/webhooks/:id/deliveries - Operaciones de feed: Varias rutas bajo
/api/v1/feeds/ - Consultas al libro mayor: Rutas bajo
/api/v1/ledger/ - Disparadores de sync: Rutas cron job para sincronización Fintoc
Multi-Tenancy
Cada registro en el sistema pertenece a una organización. El organizationId está presente en: DataFeed, RawEntry, JournalEntry, JournalLine, Anomaly, RecurringPattern, WebhookEndpoint, WebhookDelivery.
La restricción de deduplicación está scoped a la organización: UNIQUE(organizationId, entryHash). Dos organizaciones pueden tener transacciones idénticas sin colisión.
Gobernanza
Licencia
La especificación del protocolo Habilitalo y la implementación de referencia serán publicadas bajo la Licencia MIT. Esto significa:
- Cualquiera puede construir un conector sin pedir permiso.
- Cualquiera puede correr el pipeline en su propia infraestructura.
- Cualquiera puede hacer fork, modificar y redistribuir.
- No hay un tier "premium" del protocolo. El protocolo completo es abierto.
Registry Abierto
Planeamos mantener un registry abierto de conectores comunitarios en habilitalo.com. Enviar un conector requiere:
- Un manifiesto
habilitalo.connector.json. - Una función de transformación que pase la validación (chequeo de tipos input/output).
- Al menos un par de ejemplo input/output para testing.
El registry es un catálogo, no una puerta. Si tu conector cumple los requisitos de interfaz, se lista.
Auto-Hosteable
El pipeline y el sistema de eventos están diseñados para correr en cualquier despliegue PostgreSQL + Node.js. No hay dependencia de servicios cloud propietarios más allá de lo que usa la aplicación host (Balancealo). El esquema Prisma define todas las tablas requeridas, y las migraciones son versionadas y portables.
Calidad de Conectores
Distinguimos tres niveles de calidad de conectores:
- Fundacionales: Construidos y mantenidos por el equipo Habilitalo. Actualmente: Fintoc, CSV, Manual.
- Verificados: Construidos por la comunidad, revisados por el equipo Habilitalo por corrección y seguridad.
- Comunitarios: Auto-publicados. Sin revisión. Usar a discreción propia.
Versionamiento
- La versión del protocolo sigue semver. La versión actual es
0.1. - Los manifiestos de conector declaran la versión del protocolo que apuntan (
"habilitalo": "0.1"). - Los esquemas de evento se versionan independientemente.
- Los breaking changes incrementarán la versión mayor del protocolo.
Hoja de Ruta
v0.1 — Actual (Implementado)
Todo lo descrito en este documento está implementado y desplegado:
- Interfaz HabilitaloConnector y ConnectorContext
- Esquema CanonicalEvent con helper createCanonicalEvent
- ConnectorRegistry con bridgeAdapterToConnector
- Pipeline de 3 etapas: ingestar, procesar, analizar
- 3 conectores fundacionales: Fintoc, CSV, Manual
- Sistema de eventos con HMAC-SHA256, entrega al-menos-una-vez, 3 reintentos con backoff exponencial
- 4 tipos de evento
- Libro de partida doble con validación de balance, naturaleza de cuenta, anulación/reversión
- Detector de anomalías: Z-score, contraparte nueva, detección de sospecha de duplicado
- Detector de recurrencia: análisis de intervalos, clasificación de frecuencia, scoring de confianza
- Sync incremental Fintoc de 24 meses con chunks reanudables
- API de gestión de webhooks
- Aislamiento multi-tenant con deduplicación scoped por organización
v0.2 — Siguiente (Planeado)
- UI de conectores: Gestión visual de conectores en el dashboard de Balancealo.
- Conectores adicionales: SII chileno, templates CSV por banco, wizard de mapeo manual de columnas.
- Endpoints de API de análisis: Endpoints REST para detección de anomalías/recurrencias on-demand.
- Cobertura de tests: Tests unitarios para cálculo de hash, transforms de adapter, validación de balance.
- Connector SDK: Herramienta CLI para scaffoldear un nuevo conector (
npx habilitalo init my-connector). - Documentación del protocolo: Spec OpenAPI, JSON Schema para eventos y manifiestos.
v0.3 — Futuro (Considerado)
- Marketplace de conectores: Registry público con búsqueda, documentación e instalación.
- Conectores operativos y comerciales: CRM, ERP, inventario, facturación electrónica, plataformas de e-commerce.
- Pipeline streaming: Reemplazar procesamiento batch con streaming event-driven.
- Conectores cross-organización: Transferencias inter-empresa y conciliación.
- Categorización con IA: Categorización de datos basada en LLM como etapa del pipeline.
- Extracción del protocolo: Extraer en un paquete npm standalone y servicio independiente.
Apéndice A: Esquema del Manifiesto de Conector
Cada conector viene con un archivo habilitalo.connector.json que declara sus capacidades:
{
"id": "string",
"name": "string",
"version": "string (semver)",
"category": "string",
"tags": ["string"],
"inbound": {
"transport": "webhook | file | manual | api_poll",
"events": ["string"]
},
"outbound": {
"events": ["string"]
},
"auth": {
"type": "api_key | oauth2 | link_token | none",
"requiredFields": ["string"]
},
"habilitalo": "string (versión del protocolo)"
}
Descripción de campos
| Campo | Requerido | Descripción |
|---|---|---|
id | Sí | Identificador único del conector. Convención: {fuente}-v{mayor} |
name | Sí | Nombre legible del conector |
version | Sí | Versión semver de este conector |
category | Sí | Categoría amplia: financial, commerce, tax, custom |
tags | No | Tags buscables para descubrimiento en el registry |
inbound.transport | Sí | Cómo entran los datos al conector |
inbound.events | Sí | Tipos de evento de la fuente que maneja este conector |
outbound.events | Sí | Tipos de evento canónico que emite este conector |
auth.type | Sí | Mecanismo de autenticación requerido |
auth.requiredFields | No | Nombres de campos de credencial (para generación de UI) |
habilitalo | Sí | Versión del protocolo que apunta este manifiesto |
Apéndice B: Referencia de Tipos de Evento
bank.movement.created
Se emite cuando el procesador del pipeline crea un JournalEntry desde un movimiento bancario.
{
eventType: 'bank.movement.created',
eventId: string, // UUIDv4
source: 'habilitalo',
timestamp: string, // ISO8601
data: {
movementId: string,
accountId: string,
institutionId: string,
amount: number,
currency: string,
date: string, // ISO8601
description: string,
counterparty: string | null,
type: 'credit' | 'debit',
category: string | null,
categoryId: string | null,
categorySource: string | null,
rawFintocId: string,
journalEntryId: string
}
}
analysis.anomaly.detected
Se emite cuando el detector de anomalías marca una transacción.
{
eventType: 'analysis.anomaly.detected',
eventId: string,
source: 'habilitalo',
timestamp: string,
data: {
anomalyId: string,
journalLineId: string,
type: 'unusual_amount' | 'unusual_frequency'
| 'new_counterparty' | 'duplicate_suspect',
severity: 'low' | 'medium' | 'high' | 'critical',
title: string,
description: string,
detectedValue: number,
expectedValue: number | null,
deviation: number | null,
context: {
mean?: number,
stdDev?: number,
zScore?: number,
threshold?: number,
categoryAvg?: number,
categoryMax?: number,
similarTransactionsCount?: number
}
}
}
analysis.recurrence.detected
Se emite cuando el detector de recurrencia identifica o actualiza un patrón.
{
eventType: 'analysis.recurrence.detected',
eventId: string,
source: 'habilitalo',
timestamp: string,
data: {
patternId: string,
name: string,
frequency: 'daily' | 'weekly' | 'biweekly'
| 'monthly' | 'quarterly' | 'yearly' | 'custom',
intervalDays: number,
expectedAmount: number,
amountVariance: number,
currency: string,
matchPattern: string,
counterparty: string | null,
categoryId: string | null,
confidence: number, // 0.0 a 1.0
occurrenceCount: number,
firstOccurrence: string, // ISO8601
lastOccurrence: string, // ISO8601
nextExpected: string, // ISO8601
status: 'active' | 'paused' | 'ended'
}
}
ledger.entry.created
Se emite cuando un JournalEntry se postea al libro mayor.
{
eventType: 'ledger.entry.created',
eventId: string,
source: 'habilitalo',
timestamp: string,
data: {
journalEntryId: string,
entryNumber: number,
description: string,
transactionDate: string,
postDate: string,
status: 'draft' | 'posted' | 'voided',
sourceType: string | null,
sourceRef: string | null,
lines: Array<{
lineId: string,
accountId: string,
entryType: 'debit' | 'credit',
amount: number,
currency: string,
description: string | null,
counterparty: string | null,
categoryId: string | null
}>
}
}
Apéndice C: Flujo de Datos del Pipeline
Una traza completa de un solo movimiento Fintoc a través del sistema:
1. API FINTOC
GET /accounts/{id}/movements?since=2026-01-01&until=2026-01-31
Retorna: FintocMovement {
id: "mov_abc123",
amount: -45000,
currency: "CLP",
description: "COMPRA SUPERMERCADO LIDER",
post_date: "2026-01-15",
recipient_account: { holder_name: "WALMART CHILE SA" }
}
2. ADAPTER FINTOC (transform)
FintocMovement -> RawEntryInput {
transactionDate: 2026-01-15,
amount: -45000,
currency: "CLP",
description: "COMPRA SUPERMERCADO LIDER",
counterparty: "WALMART CHILE SA",
sourceId: "mov_abc123",
rawData: { ...FintocMovement original }
}
3. ETAPA DE INGESTIÓN
a. Validar: amount != 0, fecha válida, descripción presente [PASA]
b. Normalizar: date="2026-01-15", amount="45000.00",
description="compra supermercado lider"
c. Hash: SHA-256("2026-01-15|45000.00|{accountId}|
compra supermercado lider") = "a1b2c3..."
d. Verificar existente: SELECT * FROM RawEntry
WHERE organizationId=? AND entryHash="a1b2c3..." [NO ENCONTRADO]
e. Crear: INSERT INTO RawEntry (status='pending', ...)
4. ETAPA DE PROCESAMIENTO
a. Obtener: SELECT * FROM RawEntry WHERE status='pending'
b. Marcar: UPDATE RawEntry SET status='processing'
c. Determinar tipo: amount < 0 -> gasto
d. Crear JournalEntry:
Línea 1: DÉBITO {accountId} $45.000 CLP "Gasto"
Línea 2: CRÉDITO {accountId} $45.000 CLP
"COMPRA SUPERMERCADO LIDER" contraparte="WALMART CHILE SA"
e. Validar balance: débito(45000) == crédito(45000) [PASA]
f. INSERT JournalEntry + JournalLines
g. UPDATE RawEntry SET status='processed', journalEntryId=...
5. EMISIÓN DE EVENTOS (fire-and-forget)
a. Construir payload BankMovementCreatedEvent
b. Encontrar WebhookEndpoints suscritos
c. Para cada endpoint:
- CREATE WebhookDelivery (al-menos-una-vez)
- Firmar payload con HMAC-SHA256
- POST a endpoint.url con header de firma
- Si 2xx: marcar como entregado
- Si 5xx/timeout: reintentar (1s, 4s, 16s)
- Si 4xx (!429): detener, marcar como fallido
6. ANÁLISIS (asincrónico)
a. Detector de anomalías:
- Stats categoría "supermercado": media=38000, stddev=8000
- Z-score: |45000 - 38000| / 8000 = 0.875 < umbral 2.0
- Resultado: NO anómalo
b. Detector de recurrencia:
- Grupo "compra supermercado lider|bucket_X" ahora tiene 4 ocurrencias
- Intervalos: [7, 7, 8] días -> prom=7.33, stddev=0.47
- Frecuencia: semanal
- Confianza: 0.94 * 0.5 + 0.33 * 0.3 + 0.98 * 0.2 = 0.77
- Resultado: RecurringPattern creado/actualizado
Cierre
Habilitalo existe porque lo necesitábamos. Estábamos construyendo Balancealo y nos encontramos escribiendo el mismo código de integración para cada fuente — el mismo parsing de fechas, la misma normalización de montos, la misma lógica de dedup, el mismo posteo al libro mayor. Extrajimos el patrón en un protocolo porque los protocolos componen y las librerías no.
La implementación actual son 5.500 líneas de TypeScript, 24 archivos, 3 conectores, 4 tipos de evento y 2 motores de análisis. No es un juguete. Procesa movimientos bancarios reales de bancos chilenos reales para usuarios reales. Pero tampoco está completo. No hay UI. No hay suficientes conectores. La cobertura de tests necesita trabajo. Los motores de análisis necesitan endpoints de API.
Publicamos este whitepaper y la especificación del protocolo para invitar a otros a construir sobre él. Si tienes una fuente de datos que no tiene un conector Habilitalo, escribe uno. Si al pipeline le falta una feature que necesitas, proponla. Si el diseño del protocolo tiene una falla, dínoslo.
El objetivo no es construir otra plataforma. Es hacer de la integración de datos — financieros, operativos y comerciales — un problema resuelto: un protocolo que cualquiera pueda implementar, un pipeline que cualquiera pueda correr, y un registry al que cualquiera pueda contribuir.
Protocolo Habilitalo v0.1 — Marzo 2026
Franco Danioni — Digitalo SpA — habilitalo.com
Licencia MIT