~/henry-cabello
CVEnglish
← volver a ./work
En producción

monia

Un libro contable personal que se arma solo desde el correo del banco. Tengo plata en Colombia en cinco instituciones y cinco monedas, y ningún agregador llega a las cinco, así que ningún producto podía mostrarme un solo libro. monia lee el buzón: convierte las alertas de transacción de los bancos y sus PDF de extracto mensual en filas del libro. El usuario nunca escribe una transacción.

Estado
En vivo, cuenta demo pública
Construido
Siete días, 271 commits
Rol
Único ingeniero, flujo agéntico
Alcance
Del esquema al deploy en producción

Abrir el demo ↗

El libro de monia: filas de transacciones con beneficiario, cuenta, categoría y monto, un panel de flujo de caja y gasto por categoría
La pantalla de resumen de monia con saldos por cuenta y monedaLa pantalla de Fuentes: una tarjeta por banco que muestra qué ha leído monia y qué está esperando
Libro, resumen y fuentes. Cada monto conserva la moneda en que ocurrió y muestra la cifra de reporte al lado. Cada panel nombra su moneda una vez, en la esquina, en vez de repetir un código junto a cada número.

$ cat ORIGIN.md

Ningún agregador llega a mis cinco cuentas, así que construí el que lee su correo.

Mi plata vive en Bancolombia (una cuenta de ahorros y una tarjeta Visa), más Nequi, Payoneer, Meru y Binance. Se mueve en COP, USD, USDC, USDT y EUR. Plaid y sus pares no llegan a los bancos colombianos, y los bancos no publican ninguna API que yo pueda usar. Así que la respuesta estándar de conectar tus cuentas para ver un solo libro no existe para mí.

Lo que los bancos sí mandan es correo. Cada transacción produce una alerta. Cada mes produce un PDF de extracto. Eso es un registro completo y con fecha de mis finanzas, sentado en Gmail en un formato que ningún libro contable lee. monia lo lee. El buzón es la API.

$ ./bin/report --totals

271Commits
7Días
59Pull requests
21Tablas
1,028Tests unitarios
186Integración
173End to end
222Hallazgos triados

Conteos leídos del repositorio el 15 de agosto de 2026. Las cifras de integración y end-to-end están contadas desde los archivos de spec, no estimadas. La suite unitaria termina en 1,45 segundos, porque la capa de dominio no importa ni la base de datos ni React.

$ git log --since="7 days ago" --stat

Siete días, en el orden que impusieron las dependencias.

2026-08-0840 commits

El esquema, y la aritmética debajo.

2026-08-0934 commits

Más superficie, después la primera auditoría.

2026-08-1032 commits

Un despliegue en vez de un demo.

2026-08-1152 commits

El día de revisión.

2026-08-1218 commits

Extractos, y el traslape entre dos fuentes.

2026-08-1386 commits

El día largo.

2026-08-149 commits

Nombrar de dónde viene la plata.

$ ls -la ./engineering

Cuatro problemas cargaron el build: la aritmética, el buzón, un join que saltaba el buzón para el que existía, y una columna que nadie leía.

La capa de dinero fue primero, porque toda otra decisión se apoya en ella

Un solo módulo hace toda la aritmética sobre montos. Un monto es un bigint en las unidades menores de su propia moneda, y la escala sale de una columna currencies.decimals y no de una constante fija: los pesos están configurados a dos decimales acá porque Bancolombia reporta centavos en las líneas de impuestos e intereses. Ningún number de JavaScript toca nunca un monto, ni para parsearlo, ni para convertirlo, ni para formatearlo. El redondeo es medio hacia afuera del cero, así que un débito y un crédito del mismo tamaño redondean igual. Un redondeo asimétrico sesgaría los gastos contra los ingresos.

Leer un buzón son dos trabajos, no uno

Hay una pasada de puesta al día para correo nuevo, que es chica y corre a diario, y una caminata de relleno que va hacia atrás en la historia, es grande, y toma días. Toda ruta serverless tiene un tope de duración, así que un bucle del lado del servidor que pasa ese tope muere a medio vuelo y no reporta nada. En cambio cada request está acotado y aterriza su propio trabajo: escribe sus filas y mueve el piso de la historia antes de responder. Una corrida que muere, sea por una pestaña cerrada o por un deploy, pierde a lo sumo el bloque en vuelo. El bucle del navegador lee 800 mensajes por bloque; el cron lee 80.

Un INNER JOIN saltaba en silencio justo el buzón para el que existía el job

Dos tablas cargan el estado de importación. Una guarda una fila por mensaje que el importador ha mirado, con el resultado al que llegó y la evidencia que tenía. La otra guarda una fila por usuario con el día más viejo leído hasta ahora. Cuando un usuario agrega una cuenta o una plantilla, la app borra las filas de vistos que ese cambio desbloquea, para que la siguiente importación las lea otra vez. Sin ese paso, el correo que estaba a una cuenta de aterrizar queda marcado como visto para siempre. La trampa: un usuario sin fila de relleno nunca ha caminado hacia atrás, que es el mismo estado que una caminata sin terminar, y le pertenece a la cuenta más nueva, la misma para la que existe el job. La consulta que recoge a ese usuario necesita un LEFT JOIN.

El bug más grande era invisible desde el esquema

reporting_currency existía como columna, aparecía en la pantalla de ajustes, y nadie la leía. Cada página fijaba la base de almacenamiento, así que un usuario colombiano con 7.749 filas en pesos solo podía ver dólares. El arreglo es una expresión SQL con tres casos, en orden de costo. Si la moneda de reporte es igual a la base de almacenamiento, devuelve la columna congelada sin tocar, para que los números de hoy sean iguales a los de ayer por construcción. Si la fila ya está en la moneda de reporte, devuelve su monto nativo, porque un viaje de ida y vuelta por USD pierde un centavo por fila para nada. Si no, divide por la tasa de la fecha de esa fila, nunca por la de hoy. Un script de verificación después caminó las filas que de verdad necesitaban conversión, comparándolas una por una: cero diferencias en las 184.

Next.js 16React 19TypeScriptPostgresNeonDrizzleBetter AuthTailwind 4Base UIGmail APIpdfjs-distBinance APIVitestPlaywrightVercel

$ cat DECISIONS.md

Las decisiones que defendería en una entrevista, y lo que costó cada una.

Congelé la base de almacenamiento, y volví la moneda de reporte una vista en tiempo de lectura

El monto base en USD de cada fila se escribe una vez al insertar y nunca se recalcula, así que los totales históricos son estables por construcción, y por eso la moneda base no puede cambiar sin invalidar cada fila guardada. La moneda de reporte por usuario no cuesta nada cambiarla, porque no se guarda nada en ella.

El asistente nunca aporta un número

Pregúntale a un modelo de lenguaje cuánto gastaste en comida en marzo y produce una cifra segura, plausible y equivocada. En un libro contable una cifra plausible y equivocada es peor que ninguna respuesta, porque el usuario no la puede distinguir de una correcta. Así que la aplicación calcula cada cifra primero, con las mismas consultas tipadas que usan las pantallas, y le entrega al modelo una hoja de datos; el modelo dice qué cifras responden la pregunta y escribe la frase alrededor. Rechacé tres diseños por escrito antes. SQL crudo para el modelo está a una consulta mal formada de un total equivocado y a un DELETE de algo peor. Las herramientas de consulta son mejores, pero el modelo igual elige la ventana y el filtro, así que igual es dueño del número. La recuperación sobre el texto de las transacciones responde bien "¿pagué X?" y mal "¿cuánto?".

Prefiero entregar una función más angosta que siempre acierta.

Dejé el permiso de Gmail fuera del inicio de sesión

Iniciar sesión con Google pide identidad y nada más. gmail.readonly se pide aparte, desde Ajustes, y solo por las cuentas que importan correo. La razón es regulatoria y no ergonómica: es un permiso restringido de Google, y pegarlo al inicio de sesión mete la aplicación entera bajo el régimen de permisos restringidos. Publicar la pantalla de consentimiento exige entonces una evaluación anual CASA Tier 2 de un tercero, y hasta que eso pase Google vence cada refresh token a los siete días, lo que rompe el cron de importación diaria. El costo es un paso extra de consentimiento, que pagan solo los usuarios que lo necesitan.

Hice que cada request de ingesta aterrice su propio trabajo

Un bucle serverless que pasa el tope de duración muere a medio vuelo y no reporta nada, así que a ningún request se le permite depender del siguiente. Cada uno escribe sus filas y mueve el piso de la historia antes de responder, y una conexión caída pierde a lo sumo un bloque. El intercambio es progreso por bloques y más requests, y en esta plataforma la corrida larga nunca estuvo disponible.

Hice que un total sobre un subconjunto lo diga

Cuando una fila no tiene tasa de cambio la conversión devuelve NULL, y sum() la salta sin decir nada. La app cuenta esas filas y muestra el conteo al lado del total. La alternativa es un número que se ve limpio sobre datos incompletos, que es el tipo de bug que un usuario nunca reporta.

El asistente de monia respondiendo una pregunta de gasto, citando la cifra que le entregaron
El asistente, y su única regla. La respuesta cita la cifra que usó, y esa cifra coincide al centavo con el panel de cuentas, porque la aplicación la calculó y se la entregó. El modelo eligió qué número respondía la pregunta. No calculó ninguno.

$ cat method.md

271 commits en siete días, y la baranda que se ganó su lugar.

Un flujo agéntico, corrido como corro el resto de mi trabajo: revisión ciega, una segunda opinión que no es de Anthropic, y cada hallazgo triado en vez de adoptado. Lo que monia le sumó a eso fue una medición.

Un revisor borró la ruta de escritura del cursor de Gmail, su guarda de dry-run y su invalidación, las tres a la vez, y los 678 tests unitarios que existían el día cuatro siguieron en verde. Un test que pasa con el arreglo quitado no es cobertura. Ese solo resultado es la razón por la que CI ahora corre un segundo job contra un contenedor real de Postgres, y por la que el motivo está en un comentario al inicio del archivo de CI.

También fijó la regla de trabajo para el resto del build: mutar, no leer. Casi todo hallazgo de valor después de eso salió de revertir una línea y ver qué seguía en verde. Cinco rondas produjeron 222 hallazgos, archivados como 21 Altos, 83 Medios, 86 Bajos y 32 Nits.

El revisor recibe el diff y los criterios de aceptación y nada más: sin título de pull request, sin mensaje de commit, sin nombre de rama, sin veredicto previo. Un estudio controlado (arXiv:2603.18740) mantiene el código constante y varía solo la narrativa alrededor. Presentar un cambio como libre de bugs baja la detección de defectos entre 16 y 93 puntos porcentuales, asimétrico hacia los falsos negativos, y solo redactar la descripción restauró la detección en el 70% de los casos perdidos. Mi razonamiento produce ratificación, así que mi razonamiento se queda fuera del brief.

A las herramientas les doy el mismo trato, porque se han equivocado en cosas que importaban. Un proxy de shell con caché repitió un resultado de test viejo y reportó 8 fallas en un archivo que ya no existía en disco; después resumió una corrida fallida como no se recogieron tests. Y durante una revisión llamé fabricado a un archivo citado porque un git ls-tree truncado no lo mostraba. Era la cuarta entrada. De ese par salieron dos reglas: nunca dejar que una herramienta que resume sea la única fuente de un hecho que carga peso, y nunca truncar el comando que prueba una negación.

La disciplina es lo que me dice qué afirmaciones verificar y de qué suite en verde desconfiar.

$ cat commits.log

Un mensaje de commit nombra el efecto sobre la persona que usa la app.

adb0c6efix: media transferencia no es una transferencia
229c44efix: una retención de Uber no es un cobro
5c8d454fix: una autorización de $0,00 no es una transacción
bc334cefix: 97 de 400 correos necesitaban plantilla; seis de verdad la necesitaban
17bac55fix: una fila cripto mal escalada puso $10,3M en una tarjeta Visa
53eae42fix: los días de semana con tilde tumbaron 26 compras
89d8f38fix: 584 mensajes estaban trabados sin nada que los destrabara
5e03048test: el rollback de importación se prueba, no se afirma
e335e0bfeat: el buzón se lee solo, sin una pestaña abierta
62e8927fix: la app estaba renderizando en Times New Roman

Sobrevive al refactor que mueve el código.

$ cat KNOWN-ISSUES.md

Problemas conocidos

  • BrechaLa pantalla de consentimiento de Google sigue en Testing. La importación de Gmail funciona entonces solo para usuarios de prueba listados, y sus refresh tokens vencen cada semana. Publicarla implica la evaluación CASA descrita arriba.
  • BrechaUn cron en el plan Hobby de Vercel dispara en cualquier punto de su hora. Un horario acá es una hora, no un minuto, y una expresión por hora hace fallar el despliegue de una. Por eso un job está registrado cuatro veces en horas separadas en vez de una.
  • No hechoUn solo buzón con volumen. monia es multi-tenant desde el esquema y los filtros de tenencia están cubiertos por tests, pero el único buzón del que ha leído miles de mensajes es el mío.

← volver a ./work