Mercado Pago → ARCA
Detecta cada ingreso de dinero en Mercado Pago y emite sola la factura electrónica en ARCA (la ex AFIP). Reemplazó un proceso que se hacía a mano, factura por factura.
Detects every incoming payment in Mercado Pago and automatically issues the electronic invoice with ARCA, Argentina's tax authority. It replaced a process done by hand, one invoice at a time.
El problemaThe problem
En un comercio la plata entra por muchos caminos: QR, links de cobro y, sobre todo, transferencias al alias. Cada ingreso tiene que terminar facturado. Lo difícil: Mercado Pago no avisa cuando llega una transferencia directa. Hay que descubrirla en un reporte que tarda horas en generarse.
Money reaches a shop through many channels: QR codes, payment links and, above all, direct bank transfers. Every one of them must be invoiced. The hard part: Mercado Pago sends no event for direct transfers. They have to be found in a report that takes hours to generate.
Lo que construíWhat I built
Webhooks firmados para los pagos y sondeo del reporte para las transferencias. Cada ingreso se registra una sola vez, se decide si va en factura individual o en la consolidada del día, y un único worker emite las Facturas B en orden, con PDF y QR fiscal. Es multi-tenant: otro negocio se da de alta solo, sin tocar código.
Signed webhooks for payments and report polling for transfers. Each movement is recorded exactly once, routed to an individual invoice or the daily consolidated one, and a single worker issues invoices in strict order, with PDF and fiscal QR code. It's multi-tenant: a new business onboards itself with no code changes.
Mi rolMy role
Todo, en solitario: reglas de negocio, API, panel, modelo de datos, Docker y la operación en producción, incluidas las auditorías contra datos reales.
All of it, solo: business rules, API, admin panel, data model, Docker and production operations, including audits against real data.
- ~5.000
- líneas de TypeScript en la APIlines of TypeScript in the API
- 7
- pantallas en el panel Next.jsscreens in the Next.js panel
- 40
- scripts de auditoría de sólo lecturaread-only audit scripts
Umbral de ejemplo: $10.000. Lo que lo supera se factura individual; lo menor se acumula para la factura del cierre. Probá reenviar un webhook: no se factura dos veces.
Sample threshold: $10,000. Anything above it gets its own invoice; smaller amounts are batched for the end-of-day invoice. Try resending a webhook: nothing is invoiced twice.
Problemas que resolvíProblems I solved
Facturar en orden estricto con ingresos simultáneosStrictly ordered invoicing under concurrent income
Problema. ARCA exige numeración correlativa. Los ingresos llegan en ráfagas y por dos canales a la vez; emitir en paralelo genera rechazos y huecos.
Solución. Una cola en una tabla de PostgreSQL y un único worker que toma el trabajo más antiguo y lo marca dentro de la misma transacción. Sin Redis ni RabbitMQ: cero infraestructura extra y el estado se inspecciona con SQL.
Problem. ARCA requires sequential invoice numbers. Income arrives in bursts through two channels at once; issuing in parallel causes rejections and gaps.
Solution. A queue in a PostgreSQL table and a single worker that claims the oldest job inside the same transaction. No Redis or RabbitMQ: zero extra infrastructure and the state is inspectable with SQL.
Una factura con fecha equivocada: el síntoma no era la causaAn invoice with the wrong date: the symptom wasn't the cause
Problema. El PDF mostraba un día y ARCA tenía autorizada la factura con el siguiente. Parecía un bug de zona horaria ya corregido.
Causa. La fecha real enviada a ARCA nunca se guardaba: el PDF la recalculaba con otra fórmula. Corregí la raíz con una única fuente de verdad y consulté a ARCA en modo lectura cada factura histórica para encontrar cualquier otra afectada.
Problem. The PDF showed one date while ARCA had authorized the invoice on the next day. It looked like an already-fixed timezone bug.
Cause. The actual date sent to ARCA was never stored; the PDF recomputed it with a different formula. I fixed the root with a single source of truth and queried ARCA read-only for every past invoice to find any other affected one.
Un contador que se desincronizaba: rediseñar en vez de parcharA drifting counter: redesign instead of patching
Problema. Una auditoría con datos reales encontró facturas consolidadas cuyo monto no coincidía con la suma de sus movimientos, tras una caída del servidor.
Solución. Eliminé el contador. Ahora el monto se calcula sumando los movimientos reales en la misma transacción del cierre: por construcción no puede desincronizarse. Las correcciones se aplicaron con scripts en dry-run por defecto y nunca se tocó una factura ya emitida.
Problem. An audit against real data found consolidated invoices whose amount didn't match the sum of their movements, after a server outage.
Solution. I removed the counter. The amount is now computed from the real movements inside the closing transaction, so it cannot drift by construction. Fixes ran through scripts that default to dry-run, and no issued invoice was ever mutated.
Dinero que sale no es una ventaMoney going out is not a sale
Problema. Pagos a proveedores y transferencias enviadas se estaban registrando como ingresos. Y en ciertas operaciones Mercado Pago informa como pagador al propio titular.
Solución. Clasificar por la forma del payload comparando pagador y cobrador contra la cuenta real, consultar el pago en vivo a la API y reconciliar el histórico con scripts idempotentes sin tocar comprobantes emitidos.
Problem. Supplier payments and outgoing transfers were being recorded as income. On some operations Mercado Pago also reports the account owner as the payer.
Solution. Classify by payload shape, comparing payer and collector against the real account, query the payment live from the API, and reconcile history with idempotent scripts without touching issued invoices.
ArquitecturaArchitecture
Ver código: el worker que toma trabajos de a unoView code: the worker that claims one job at a time (worker.ts)
// Toma el trabajo PENDING más antiguo y lo marca PROCESSING dentro de la
// misma transacción, para que dos workers nunca tomen el mismo trabajo.
async function claimNextJob() {
return prisma.$transaction(async (tx) => {
const next = await tx.invoiceJob.findFirst({
where: { status: "PENDING" },
orderBy: { createdAt: "asc" },
});
if (!next) return null;
return tx.invoiceJob.update({
where: { id: next.id },
data: { status: "PROCESSING" },
include: { tenant: true, movements: { select: { occurredAt: true } } },
});
});
}
// Si la emisión falla, el job queda en ERROR para revisión humana:
// reintentar a ciegas un comprobante fiscal puede duplicar una factura.
- TypeScript
- Node.js 22
- Fastify
- Next.js
- PostgreSQL 16
- Prisma
- Docker Compose
- Cloudflare Tunnel
- Mercado Pago API
- ARCA WSFEv1
- AES-256-GCM