ledgerbridge

LedgerBridge: sync bidireccional de facturas que sobrevive al mundo real

Mantiene un sistema de facturación interno y QuickBooks Online en lockstep ante webhooks duplicados, fuera de orden y perdidos: cada escritura idempotente, cada conflicto retenido para un humano, cada evento reproducible.

Solo: diseño, desarrollo, infraestructura
TypeScriptFastifyQuickBooks APIDrizzlePostgresZodVitest

TL;DR

  • Sync bidireccional de facturas entre un sistema interno y QuickBooks Online, hecho para sobrevivir webhooks duplicados, fuera de orden y perdidos.
  • Cada escritura es idempotente: un create que hace timeout después de haber impactado se adopta, nunca se duplica.
  • 87 tests de backend + 15 de web, todos deterministas contra un esquema Postgres real, sin mocks de la base.

Problema

Un webhook es un ping, no la verdad.

Las integraciones reales nunca entregan eventos limpios. Los webhooks llegan duplicados, fuera de orden, con medio payload, o no llegan. Si confiás en el payload, cobrás dos veces, perdés una edición o dejás dos ledgers desincronizados. LedgerBridge trata cada evento como una señal para ir a releer el estado real, y lo aplica exactamente una vez.

El puente: una factura sale del sistema interno, cae en el outbox durable y se aplica a QuickBooks exactamente una vez

Arquitectura

sistema interno ─webhook─▶ ingest (verifica · dedup · encola)
                                │
                          outbox durable ─▶ worker con lease: relee → mapea → resuelve → aplica
                                │                                              │
   el reconciler barre el drift ◀┘        escritura idempotente ─▶ QuickBooks Online ┘

Dos ideas sostienen todo el diseño: releer en vez de confiar en el payload, e idempotencia por id externo. Un outbox en Postgres drenado por un único worker con lease (FOR UPDATE SKIP LOCKED) hace que los eventos duplicados o fuera de orden nunca se apliquen dos veces.

Diff campo a campo entre el registro interno y su contraparte en QuickBooks, con el drift y el audit trail al lado

Decisiones clave

Releer en cada evento, no el cuerpo del webhook

Elegí releer el estado actual desde la fuente antes de aplicar, en lugar de actuar sobre el payload. Trade-off: una vuelta extra a la API por evento, pero los webhooks fuera de orden y parciales dejan de ser un problema de una sola vez.

Conflictos flag-and-hold, no last-writer-wins

Cuando los dos lados editan el mismo campo, elegí retener ambas direcciones y encolar el conflicto para un humano, en lugar de elegir un ganador en silencio. Trade-off: alguien tiene que resolverlo, pero un last-writer-wins ciego sobre relojes no sincronizados pierde plata real sin avisar.

El resolvedor de conflictos: los dos lados de una divergencia en el mismo campo, retenidos hasta que un operador elige el ganador

Un outbox durable en vez de una cola gestionada

Elegí una tabla outbox en Postgres más un worker con lease sobre SQS o Inngest. Trade-off: me hago cargo de la lógica de retry y backoff, pero todo el pipeline es una sola tabla consultable y reproducible, y el write-up nombra la cola gestionada como el upgrade de producción.

Idempotente por id externo

Antes de crear en QuickBooks, elegí chequear primero por número de documento. Trade-off: una lectura extra, pero un create que hizo timeout después de haber impactado se adopta en lugar de duplicarse. Esa es la única falla que corrompería un ledger en silencio.

El log de eventos: cada webhook idempotente, reintentado con backoff, y mandado a dead-letter al agotarse, con replay de un click

Un webhook es un ping, no la verdad. Una vez que cada evento solo significó "andá a releer y converger", la idempotencia y el manejo de conflictos dejaron de ser casos especiales y pasaron a ser el default.

la idea sobre la que se apoya todo el motor

Más difícil de lo esperado

La prevención de loops. Una escritura saliente a QuickBooks dispara un webhook de vuelta al instante; sin detección de eco (un hash de estado más el SyncToken de QuickBooks) los dos ledgers hacen ping-pong para siempre. Probar que el eco se descarta en ambas direcciones, interno a QBO y de vuelta, fue el diseño de tests más cuidadoso del proyecto.

Resultados

  • 87 + 15: tests deterministas, backend + web, Postgres real, sin mocks de la base
  • 10: flujos end-to-end reproducibles, cada uno atado a un test
  • 9: hallazgos de una revisión de seguridad independiente, todos corregidos

Live + código

El dashboard en vivo corre sobre datos sembrados, sin login: mirá los eventos fluir, abrí un conflicto, reencolá un dead-letter. El panel de demo maneja un sandbox real de QuickBooks.