
Conciliación de pagos multi-rail en México: cuadrar Stripe, Mercado Pago, SPEI y OXXO contra un solo ledger que tú controlas
Cada rail (tarjetas Stripe, Mercado Pago, SPEI, efectivo OXXO) es su propio silo, con su tiempo de liquidación, comisiones y formato de reporte. Solo respondes "¿recibimos cada peso?" conciliando todos los rails contra un ledger propio: modela los pagos esperados, jala la fuente de verdad de cada rail, empata por external_id o monto+fecha con tolerancia (idempotente, neto vs bruto) y clasifica en auto-conciliado, por-revisar o atención-a-cliente.
Por qué “¿recibimos cada peso?” es tan difícil de responder
Si vendes en México, casi nunca cobras por un solo rail. Tienes tarjetas por Stripe, un checkout de Mercado Pago, transferencias SPEI que caen a una CLABE, y vouchers de OXXO para la gente que paga en efectivo. Cada uno corre en paralelo, todo el tiempo, y cada uno te manda dinero a su propio ritmo.
El problema es que cada rail es un silo. Stripe tiene su tiempo de liquidación, sus comisiones y su formato de reporte. Mercado Pago tiene los suyos. SPEI llega rápido pero por otro canal. OXXO es efectivo, así que se queda “esperando pago” hasta que el cliente camine a la tienda. Y la documentación de cada pasarela solo concilia su rail: te dice si Stripe te pagó bien lo de Stripe, pero no sabe nada de esa transferencia SPEI ni de ese voucher de OXXO que venció sin pagarse.
La pregunta que de verdad importa al negocio —“¿recibimos cada peso que nos debían?”— cruza todos los rails. Por eso ningún tablero de pasarela, por bonito que sea, la puede responder solo. Ninguno ve el panorama completo.
La tesis de este post, desde el arranque: la respondes conciliando cada rail contra un solo ledger que tú controlas. Una fuente de verdad propia, que ninguna pasarela te da, donde modelas lo que esperabas cobrar y contra eso cuadras lo que de verdad llegó.
Y un encuadre honesto antes de seguir: soy ingeniero, no contador. Esto es un patrón de conciliación para el sistema —cuadrar qué dinero entró por cada rail—, no los libros contables. El lado fiscal (CFDI, la factura global) es otra cosa; eso déjalo con tu contador. Aquí hablo de la plomería.
Si todavía no tienes claro cuáles son los rieles que estás conciliando, primero dale una pasada a las pasarelas de pago en México y vuelve.
Un ledger vs cuatro tableros: ¿qué puede responder cada uno realmente?
Vale la pena ser muy concreto sobre qué te puede contestar cada herramienta, porque ahí está el malentendido que hace perder dinero (o creer que se perdió).
La conciliación de un solo rail es lo que te dan las pasarelas de fábrica. Las herramientas de Stripe concilian lo que Stripe liquidó. El reporte de Mercado Pago concilia Mercado Pago. Cada tablero contesta una pregunta legítima pero acotada: “¿mi rail pagó correctamente?”. Lo que ninguno te puede decir es si llegó cada peso a través de todos los rails, ni si un cargo se creó y nunca liquidó en otro canal, ni si un voucher de OXXO venció sin pagarse.
La conciliación multi-rail contra un solo ledger es otra cosa. Cruzas la fuente de verdad de cada rail contra tus propias filas de pagos esperados. Solo ese modelo saca a la luz los huecos: un cargo que se creó pero nunca se asentó, un duplicado que se contó dos veces, un depósito SPEI que no amarra con ninguna orden.
La diferencia de fondo: tu ledger es la respuesta autoritativa; cada reporte de pasarela es apenas un insumo para él. En el momento en que tratas el tablero de una pasarela como tu fuente de verdad, ya perdiste la capacidad de responder la pregunta del negocio.
Un solo rail (tablero de pasarela)
- Concilia solo SU rail
- Responde: ¿mi rail pagó correctamente?
- No ve el SPEI ni el voucher de OXXO de otro canal
- No detecta un cargo que nunca liquidó en otro rail
- Es un insumo, no la verdad
Multi-rail contra un ledger propio
- Cruza cada rail contra tus pagos esperados
- Responde: ¿recibimos cada peso, en todos los rails?
- Saca a la luz cargos sin liquidar y duplicados
- Detecta vouchers de OXXO vencidos sin pago
- Es tu respuesta autoritativa
Primero modela tu propio ledger
Todo lo demás se empata contra esto, así que empieza por aquí.
Una fila por cada pago esperado, creada en el momento en que creas el cargo —no cuando cae el dinero. Esa es la inversión mental clave: el ledger nace con la expectativa, y la conciliación es el proceso de irle cumpliendo esa expectativa a cada fila.
Las columnas centrales: order_id, amount, currency, rail, external_id, status, más un created_at y un settled_at que llenas al empatar. Modela los estados con honestidad —pending, paid, partial, failed, refunded— y para efectivo, un estado awaiting_payment hasta que el cliente pague en la tienda.
Dos detalles que la gente olvida y luego no cuadran: guarda el monto como bruto y lleva la comisión por rail aparte, para poder conciliar neto vs bruto después (el payout que cae al banco es bruto menos comisión). Y el external_id es tu llave estable por rail: el id del PaymentIntent o charge de Stripe, el id de payment de Mercado Pago, la referencia del SPEI, el id del voucher de OXXO.
-- Ilustrativo: el esquema mínimo de tu ledger.
-- Ajusta tipos y columnas a tu stack; lo importante es el modelo.
CREATE TABLE expected_payments (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL,
rail TEXT NOT NULL, -- 'stripe' | 'mercadopago' | 'spei' | 'oxxo'
external_id TEXT, -- llave estable del rail (puede llegar después)
amount_gross NUMERIC(14,2) NOT NULL, -- lo que esperas cobrar, BRUTO
fee NUMERIC(14,2), -- comisión del rail (se modela por rail, no se hardcodea)
currency TEXT NOT NULL DEFAULT 'MXN',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | awaiting_payment | paid | partial | failed | refunded
settlement_fx NUMERIC(14,6), -- FX de liquidación si es multi-moneda
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settled_at TIMESTAMPTZ, -- se llena al conciliar
UNIQUE (rail, external_id) -- ancla de idempotencia por rail
);
Jala la fuente de verdad de cada rail
Cada rail tiene un reporte o webhook que es su fuente de verdad. Estos son los nombres exactos —no los inventes ni los adivines.
Stripe: la API de BalanceTransactions (puedes reproducirlas para reconstruir tu balance) más el Payout Reconciliation Report, que desglosa cada payout automático que cayó a tu banco, agrupado por reporting category. Entre los dos tienes el cargo y la liquidación.
Mercado Pago: el reporte de liquidaciones / movimientos. Es el equivalente al desglose de payout de Stripe, del lado de MP. Para jalar bien ese lado, revisa cómo integrar Mercado Pago.
SPEI: los webhooks de CLABE virtual —una CLABE por cliente para que sepas quién pagó (el patrón de CLABE virtual de Bitso/STP). Aquí te dejo el artículo de Bitso Business sobre SPEI, CLABEs virtuales, webhooks y conciliación.
OXXO: el webhook del voucher. Es efectivo, así que se queda en awaiting_payment con una expiración de voucher hasta que el cliente paga en la tienda, y entonces dispara el evento de pagado. Los webhooks de OXXO y SPEI que alimentan la conciliación los cubrí aparte.
Una regla que aplica a los cuatro: cada jalada es un insumo para tu ledger, nunca el ledger mismo. Y como vas a re-jalar reportes y reprocesar webhooks, todo esto tiene que ser idempotente.
Empata por external_id o monto+fecha: idempotente, neto vs bruto
Aquí vive el motor de conciliación. La lógica tiene un orden claro.
El match primario es por la llave estable, el external_id: el join más limpio y exacto, del registro del rail a la fila de tu ledger. Cuando lo tienes, no hay ambigüedad.
El fallback es por monto + fecha dentro de una ventana de tolerancia, para los rails donde no te regresan un id limpio —por ejemplo un depósito SPEI pelón que solo trae monto y fecha. La tolerancia existe precisamente porque los relojes y los centavos no siempre coinciden al instante.
La idempotencia no se negocia: re-jalar un reporte o reprocesar un webhook no debe contar doble. Deduplicas por el id del registro del rail (de ahí el UNIQUE (rail, external_id) del esquema). Esto es lo mismo que cuidas al verificar la firma de los webhooks de Stripe contra los que concilias: un evento que llega dos veces no puede asentarse dos veces.
Y el punto que rompe más conciliaciones: neto vs bruto. El payout que cae a tu banco es bruto menos la comisión del rail. Empatas contra el bruto y luego verificas la comisión; si no, tu ledger jamás va a cuadrar. Las comisiones varían por rail y cambian con el tiempo, así que modela la comisión como dato por rail, nunca la hardcodees —confírmala contra el reporte actual de cada proveedor. Lo mismo con multi-moneda: captura el FX de liquidación del reporte del rail, no lo infieras.
# Ilustrativo: empatar una línea de balance_transactions / payout-report
# de Stripe contra una fila del ledger. Confirma los nombres de campos
# y las comisiones contra los docs ACTUALES de cada proveedor.
# linea_rail = registro del reporte del rail (trae amount_gross, net_amount, fee);
# fila = fila de tu ledger (trae el amount_gross esperado).
def conciliar_linea(linea_rail, ledger, tolerancia=Decimal("0.50")):
# 1) Idempotencia: si ya asentamos este registro del rail, no lo cuentes de nuevo.
if ledger.ya_asentado(linea_rail.rail, linea_rail.external_id):
return "duplicado_ignorado"
# 2) Match primario por llave estable.
fila = ledger.buscar_por_external_id(linea_rail.rail, linea_rail.external_id)
# 3) Fallback: monto + fecha dentro de una ventana de tolerancia.
if fila is None:
fila = ledger.buscar_por_monto_fecha(
rail=linea_rail.rail,
monto=linea_rail.amount_gross,
fecha=linea_rail.fecha,
tolerancia=tolerancia,
)
if fila is None:
return "sin_empate" # va a atencion-a-cliente
# 4) Neto vs bruto: el payout es bruto menos comisión.
# Empatamos contra el bruto y verificamos la comisión reportada por el rail.
neto_esperado = fila.amount_gross - linea_rail.fee
if abs(linea_rail.net_amount - neto_esperado) > tolerancia:
return "por_revisar" # discrepancia de comisión o monto
fila.marcar_conciliado(
external_id=linea_rail.external_id,
fee=linea_rail.fee,
settlement_fx=linea_rail.settlement_fx,
settled_at=linea_rail.fecha,
)
return "auto_conciliado"
Los desfases de liquidación que rompen la conciliación ingenua del mismo día
Este es el error operativo que más “pierde” dinero que en realidad está bien: esperar que un pago caiga el mismo día que creaste el cargo. No lo esperes. Esa suposición es la razón número uno por la que una conciliación ingenua marca faltantes que no existen.
Las tarjetas por Stripe liquidan con un delay de payout: el cargo y el payout son días distintos. SPEI suele caer rápido, pero el timing del webhook sigue su propio reloj. Y OXXO es efectivo: puede quedarse “esperando pago” por días —hasta que el cliente camine a una tienda—, liquida más lento que las tarjetas, y además el voucher puede vencer sin pagarse. Tres relojes distintos para tres rails distintos.
La salvedad honesta: los tiempos exactos de liquidación difieren por cuenta y cambian con el tiempo. Como en 2026, confírmalos contra los docs actuales de cada proveedor; no hardcodees un “T+N” en tu lógica. Y conoce el límite de día y la zona horaria de tu reporte —por ejemplo, las ventanas de los reportes de Stripe se calculan sobre un día de corte (confirma el tuyo)— para no partir el volumen de un día entre dos reportes.
Clasifica los resultados: auto-conciliado, por-revisar, atención-a-cliente
Empatar no sirve de nada si no haces algo con el resultado. Tres buckets convierten la conciliación en un flujo operativo en lugar de un pánico de hoja de cálculo una vez al mes.
Auto-conciliado: matches por llave estable o por monto+fecha dentro de tolerancia. Estos se cierran solos, ningún humano los toca. Y este bucket debería ser la mayoría aplastante.
Por-revisar: diferencias de monto, discrepancias de comisión, casos borde de FX, un payout que no amarra con sus transacciones. Un ingeniero o alguien de finanzas les echa el ojo.
Atención-a-cliente: un cargo sin liquidación después de su ventana esperada, un voucher de OXXO que venció sin pagarse, un depósito SPEI que no puedes amarrar a ninguna orden. Aquí alguien tiene que contactar al cliente.
El punto de los buckets: la conciliación se vuelve un flujo, no una emergencia mensual. Y esta es justo la ventaja operativa —los docs de cada pasarela concilian su rail; el bucketing más el cruce contra un solo ledger es la parte que se saltan.
- 1. Modela tu ledgeruna fila por pago esperado; bruto + comisión por rail; estados honestos
- 2. Jala la fuente de verdad de cada railStripe BalanceTransactions + payout report, MP liquidaciones, SPEI/OXXO webhooks
- 3. Empata por external_id o monto+fechaidempotente, neto vs bruto, comisión como dato por rail
- 4. Maneja los desfases de liquidacióntarjetas con delay, SPEI rápido, OXXO lento; nada el mismo día
- 5. Clasifica en bucketsauto-conciliado / por-revisar / atención-a-cliente
Preguntas frecuentes
¿No basta con confiar en el tablero de cada pasarela? No. Cada tablero concilia solo su propio rail; no te puede decir que un peso se perdió en un rail distinto ni que un cargo nunca liquidó. Esa respuesta solo sale del cruce contra un ledger propio.
¿Cómo manejo que OXXO se quede “esperando pago” por días?
Modela el estado awaiting_payment y la expiración del voucher de forma explícita. No trates un voucher sin pagar como ingreso perdido hasta que venza, ni como recibido hasta que dispare el evento de pagado. Es efectivo: tiene su propio ritmo.
Neto vs bruto, ¿cuál guardo? Guarda el bruto y la comisión por rail por separado. El payout al banco es neto, así que concilias bruto-menos-comisión contra ese payout. Si solo guardas el neto, nunca verificas la comisión.
¿Esto reemplaza a mi contador o mi facturación CFDI? No. Esto es la conciliación de ingeniería de qué dinero llegó; el lado fiscal (CFDI, la factura global) es aparte. La factura global tiene un plazo de 24 horas (regla 2.7.1.21 RMF 2026) —una obligación que ya está en pie desde que el plazo bajó de 72h a 24h en 2022, no un cambio de 2026—. Los libros fiscales déjalos con tu contador, y trata las reglas del SAT como sensibles al tiempo: como en 2026, confirma lo vigente.
¿Qué tiempo de liquidación o porcentaje de comisión hardcodeo? Ninguno. Varían por cuenta y cambian. Jálalos del reporte y los docs actuales de cada proveedor, y modélalos como dato, no como constante.
Cuádralo una vez, luego mantenlo cuadrado
El único que responde “¿recibimos cada peso?” a través de todos los rails es el ledger que tú controlas. Constrúyelo una vez, córrelo en un schedule, y el bucket de auto-conciliado carga el peso mientras los humanos solo tocan las excepciones.
Honesto, otra vez: esto es la arquitectura. Los tiempos de liquidación y las comisiones varían por cuenta y cambian, así que confírmalos contra cada proveedor, y empareja el patrón con tu contador para los libros. Si quieres más sobre este terreno, tengo más guías de pagos LATAM.
Esta es justo la plomería multi-rail que armo en producción para clientes en México: un ledger propio, los cuatro rails cruzados contra él, y los exception buckets corriendo solos.