El dinero no se mueve dos veces

Un backend transaccional no falla el día que se cae. Falla el día que funciona dos veces.
Casi todo lo que se escribe sobre backend de pagos se queda en la superficie: "usá una transacción de base de datos", "agregá un idempotency_key". Eso es el titular, no el problema. El problema real aparece cuando una operación de dinero cruza tres sistemas que no comparten transacción: tu base de datos, el proveedor de pago (PSP) y la cola de trabajos. Ninguno de los tres puede hacer rollback de los otros.
Este artículo es sobre esa costura. Qué se rompe, por qué, y cuál es el diseño que sostiene.
No existe el commit distribuido
Tomemos una operación mínima: cobrar 100 USD a una tarjeta y acreditar 100 USD al saldo del usuario.
DB::transaction(function () use ($user, $amount) {
$charge = $psp->charge($user->card, $amount); // red
$user->balance += $amount; // base de datos
$user->save();
});
Este código está mal, y no de forma sutil. Está mal por tres razones distintas.
La llamada de red está dentro de la transacción. Mientras el PSP tarda cuatro segundos, estás manteniendo locks de fila abiertos. Bajo carga eso es un amontonamiento de conexiones y un deadlock. Una transacción de base de datos debe durar microsegundos, no segundos.
El rollback es una mentira. Si $user->save() falla, la base revierte. El cobro en el PSP no. Acabás de sacarle 100 USD a alguien sin acreditárselos. La transacción te dio la ilusión de atomicidad sobre un sistema que no participa de ella.
Un timeout no es un fallo. Si $psp->charge() lanza timeout, no sabés si el cobro ocurrió. Se perdió la respuesta, no necesariamente la operación. Reintentar a ciegas cobra dos veces.

La conclusión incómoda: atomicidad entre tu base y un tercero no existe. Lo que sí podés construir es algo más débil pero suficiente: convergencia. El sistema puede quedar temporalmente inconsistente, siempre que tenga un camino determinista de vuelta al estado correcto.
Idempotencia: no es una columna, es un contrato
La formulación útil no es "no repetir", es: ejecutar la operación N veces produce exactamente el mismo efecto observable que ejecutarla una vez.
Dos consecuencias que casi siempre se pasan por alto.
La clave la genera el cliente, no el servidor. Si la genera el servidor, cada reintento genera una clave nueva y no protege de nada. La clave la genera quien inicia la intención y la mantiene estable a través de todos los reintentos.
La clave debe cubrir el cuerpo del request, no solo la operación. Si alguien reusa la misma clave con un monto distinto, eso no es un reintento: es un error del cliente, y devolver alegremente la respuesta cacheada oculta un bug. Guardá un hash del cuerpo junto a la clave.
CREATE TABLE idempotency_keys (
key VARCHAR(64) NOT NULL,
scope VARCHAR(64) NOT NULL, -- endpoint + tenant
request_hash CHAR(64) NOT NULL, -- sha256 del payload canónico
state ENUM('in_flight','done') NOT NULL,
response_code SMALLINT NULL,
response_body JSON NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (scope, key)
);
Y el flujo:
$row = IdempotencyKey::firstOrCreate(
['scope' => $scope, 'key' => $key],
['request_hash' => $hash, 'state' => 'in_flight']
);
if ($row->request_hash !== $hash) {
throw new IdempotencyConflict(); // 422: misma clave, otro cuerpo
}
if ($row->state === 'in_flight' && ! $row->wasRecentlyCreated) {
return response('', 409); // hay una corrida en curso
}
if ($row->state === 'done') {
return response($row->response_body, $row->response_code);
}
// ...ejecutar de verdad, y al final marcar 'done' con la respuesta
El detalle que hace toda la diferencia: el candado es el índice único de la base de datos, no un if (exists) en código de aplicación. Dos requests concurrentes con la misma clave chocan en el INSERT; uno gana, el otro recibe la fila existente. Un chequeo previo en PHP tiene una ventana de carrera entre el SELECT y el INSERT que bajo concurrencia real se dispara todos los días.
El estado in_flight también importa: sin él, dos requests simultáneos ven "no hay respuesta guardada" y ambos ejecutan.
El saldo nunca es una columna
El error estructural más caro que se comete en un backend de dinero es este:
UPDATE users SET balance = balance + 100 WHERE id = ?;
Es atómico, es rápido, y es indefendible. Porque cuando el saldo no cuadra — y algún día no cuadra — no hay forma de saber por qué. Perdiste la historia. Tenés un número sin procedencia.
La contabilidad resolvió esto en el siglo XV. Un saldo no se almacena: se deriva.
CREATE TABLE ledger_entries (
id BIGINT PRIMARY KEY,
transaction_id BIGINT NOT NULL, -- agrupa las patas de un mismo asiento
account_id BIGINT NOT NULL,
amount_minor BIGINT NOT NULL, -- centavos, con signo. Nunca float.
currency CHAR(3) NOT NULL,
created_at TIMESTAMP NOT NULL
);
Con dos invariantes no negociables:
- Los asientos son inmutables. No hay
UPDATE, no hayDELETE. Un error se corrige con un asiento de reverso, igual que en contabilidad real. Eso preserva la auditoría. - Cada asiento suma cero. El dinero no aparece ni desaparece: se mueve entre cuentas.
Un cobro de 100 USD con 3 USD de comisión, entonces, no son dos filas sino cuatro:
psp:clearing→+10000user:42:available→+9700revenue:fees→+300liability:user_funds→-10000
Suma cero. Y ahora la pregunta "¿por qué este usuario tiene este saldo?" tiene una respuesta ejecutable, no una conjetura.
"Derivar el saldo es lento"
Sí, si lo derivás siempre. La solución no es volver a la columna mutable: es un snapshot. Una tabla de saldos materializados con el id del último asiento incluido. La lectura toma el snapshot y suma solo los asientos posteriores. El snapshot es una caché reconstruible, no la fuente de verdad — y esa distinción es la que te salva cuando se corrompe, porque se recalcula desde cero.
La regla, dicha de una vez: la caché puede mentir; el libro mayor no. Si tu sistema tiene un camino en el que el saldo se mueve sin que se escriba un asiento, ese camino es un bug con fecha pendiente.
Recomponer la atomicidad que la red te quitó
Sabemos que no podemos hacer el cobro atómico. Lo que hacemos entonces es partirlo en pasos que sí son individualmente atómicos, y hacer que el conjunto converja.
Paso 1 — registrar la intención antes de tocar la red.
$payment = Payment::create([
'state' => 'pending',
'amount' => $amount,
'idempotency_key' => $key,
]); // COMMIT. La transacción de base de datos termina acá.
Ahora existe evidencia durable de que la operación empezó, antes de que ocurra. Si el proceso muere en el paso siguiente, hay un registro pending que alguien puede reconciliar. Sin este paso, un crash deja un cobro en el PSP que tu sistema no sabe que existe. Eso es dinero huérfano, y aparece semanas después en un reclamo.
Paso 2 — llamar al PSP fuera de toda transacción, y pasarle tu clave de idempotencia. Casi todos los proveedores serios la aceptan. Esa es tu protección real contra el doble cobro por timeout: si reintentás con la misma clave, el proveedor devuelve el cobro original en vez de crear uno nuevo.
Paso 3 — aplicar el resultado, idempotentemente.
DB::transaction(function () use ($payment, $charge) {
$fresh = Payment::whereKey($payment->id)
->where('state', 'pending') // la guarda que hace esto seguro
->lockForUpdate()
->first();
if (! $fresh) {
return; // otro proceso ya lo aplicó
}
$ledger->post($fresh, $charge); // los cuatro asientos, suma cero
$fresh->update(['state' => 'settled']);
});
El where('state','pending') es lo que convierte una operación peligrosa en una segura: la transición de estado y el asiento contable ocurren en la misma transacción de base de datos, y solo pueden ocurrir una vez. Un segundo intento no encuentra la fila y termina sin hacer nada — que es precisamente la definición de idempotente.
Nótese que la única transacción de base de datos que queda es corta y no contiene ninguna llamada de red.
La parte que casi nadie construye: la reconciliación
Todo lo anterior reduce la probabilidad de inconsistencia. No la elimina. Siempre queda una ventana: el proceso muere entre el paso 2 y el 3.
Un sistema de pagos sin reconciliación es un sistema que confía en no fallar. Los que sobreviven en producción tienen un proceso aburrido que corre cada pocos minutos:
- Buscar los
paymentsenpendingcon más de N minutos de antigüedad. - Preguntarle al PSP por esa clave de idempotencia: ¿existe este cobro?
- Si existe, aplicar el paso 3 — que es idempotente, así que es seguro.
- Si no existe y venció, marcar
expired. - Si el proveedor responde algo que no encaja con ningún estado esperado, no adivinar: marcar para revisión humana y alertar.
Ese último punto es el que separa un sistema financiero de una aplicación normal. En dinero, fallar ruidosamente es mejor que resolver creativamente. Un caso ambiguo resuelto por una heurística es un descuadre que se descubre en el cierre de mes, cuando ya son cientos.
Y encima de todo, un chequeo invariante que corre continuamente y que es la única métrica que de verdad importa:
-- Debe devolver cero filas. Siempre. Si no, hay que parar y mirar.
SELECT transaction_id
FROM ledger_entries
GROUP BY transaction_id
HAVING SUM(amount_minor) <> 0;
Las trampas que solo se aprenden en producción
Los floats no existen en dinero. Enteros en la unidad mínima, o decimales de precisión fija. Y la decisión de redondeo del cálculo de comisiones debe vivir en un solo lugar: si dos módulos redondean distinto, el descuadre es de un centavo por operación — invisible por semanas, y después imposible de rastrear.
Los callbacks del proveedor llegan desordenados, duplicados y a veces antes que tu propia respuesta. El webhook de "cobro confirmado" puede llegar mientras tu request original sigue esperando. Por eso el manejador de webhooks debe pasar por la misma guarda de transición de estado que el flujo principal. Si tenés dos caminos que aplican el mismo efecto, tenés dos oportunidades de aplicarlo dos veces.
Los estados deben ser una máquina, no un string. Cualquier transición fuera del grafo declarado debe lanzar excepción. Un campo de texto libre que cualquier parte del código puede sobreescribir garantiza que, tarde o temprano, algo pase de refunded a settled.
Reintentar no es gratis. Un reintento sin backoff exponencial y sin jitter convierte una caída pasajera del proveedor en una denegación de servicio que vos mismo ejecutás contra él, justo cuando está intentando recuperarse.
Una feature flag apagada puede mover dinero sin asentarlo. Si el ledger está detrás de una bandera y el flujo de pago no, el sistema queda en un estado que ninguna prueba cubre: el saldo cambia y el libro mayor no se entera. Toda bandera que gobierna un camino de dinero necesita definirse en términos de qué invariante deja de cumplirse cuando está apagada.
Cierre
Si tuviera que comprimir todo a cuatro frases:
- La atomicidad distribuida no existe: construí convergencia, no ilusión de transacción.
- La idempotencia se apoya en un índice único, no en un
if. - El saldo se deriva de asientos inmutables; una columna mutable es historia perdida.
- Sin reconciliación no hay sistema, solo un sistema que todavía no falló.
Nada de esto es exótico. Es lo que separa un backend que procesa pagos de uno que además puede demostrar qué pagos procesó.
Comentarios
Todavía no hay comentarios. Empieza tú.