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



$ 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
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.
El esquema, y la aritmética debajo.
Más superficie, después la primera auditoría.
Un despliegue en vez de un demo.
El día de revisión.
Extractos, y el traslape entre dos fuentes.
El día largo.
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.
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.
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.
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.
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.
$ cat DECISIONS.md
Las decisiones que defendería en una entrevista, y lo que costó cada una.
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.
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.
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.
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.
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.

$ 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.
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.