Diseñando un Sistema de Pagos
Los sistemas de pagos están entre los componentes más críticos y complejos en cualquier negocio. Un solo error puede resultar en pérdida financiera, violaciones regulatorias o pérdida de confianza del cliente. Diseñar un sistema de pagos confiable requiere comprensión profunda de sistemas distribuidos, seguridad y principios de contabilidad financiera.
Objetivos Clave de Diseño
- Corrección: El dinero nunca debe perderse, duplicarse o enrutarse mal
- Fiabilidad: 99.999% de disponibilidad para procesamiento de pagos
- Seguridad: Cumplimiento completo de PCI-DSS y prevención de fraude
- Auditabilidad: Cada transacción debe ser rastreable de extremo a extremo
- Escalabilidad: Manejar cargas pico (ej. Black Friday, ventas flash)
Flujo de Procesamiento de Pagos
Un flujo de pago típico involucra múltiples etapas y sistemas externos. Entender este flujo es esencial antes de profundizar en la arquitectura.
// Simplified payment processing flow
enum PaymentStatus {
INITIATED = "INITIATED",
AUTHORIZED = "AUTHORIZED",
CAPTURED = "CAPTURED",
SETTLED = "SETTLED",
FAILED = "FAILED",
REFUNDED = "REFUNDED",
}
interface PaymentRequest {
idempotencyKey: string;
amount: number;
currency: string;
merchantId: string;
customerId: string;
paymentMethod: PaymentMethodDetails;
metadata?: Record<string, string>;
}
interface PaymentRecord {
id: string;
idempotencyKey: string;
status: PaymentStatus;
amount: number;
currency: string;
merchantId: string;
customerId: string;
gatewayTransactionId?: string;
createdAt: Date;
updatedAt: Date;
statusHistory: StatusTransition[];
}
async function processPayment(request: PaymentRequest): Promise<PaymentRecord> {
// Step 1: Idempotency check
const existing = await findByIdempotencyKey(request.idempotencyKey);
if (existing) return existing;
// Step 2: Create payment record
const payment = await createPaymentRecord(request);
// Step 3: Risk assessment / fraud check
const riskResult = await assessRisk(payment);
if (riskResult.blocked) {
return await updateStatus(payment, PaymentStatus.FAILED, "Risk check failed");
}
// Step 4: Authorize with payment gateway
const authResult = await authorizePayment(payment);
if (!authResult.success) {
return await updateStatus(payment, PaymentStatus.FAILED, authResult.reason);
}
// Step 5: Capture the payment
const captureResult = await capturePayment(payment, authResult.transactionId);
if (!captureResult.success) {
await voidAuthorization(authResult.transactionId);
return await updateStatus(payment, PaymentStatus.FAILED, "Capture failed");
}
// Step 6: Update ledger entries
await createLedgerEntries(payment);
return await updateStatus(payment, PaymentStatus.CAPTURED);
}El flujo anterior es simplificado. En producción, cada paso involucra reintentos, timeouts, circuit breakers y manejo cuidadoso de errores. El proceso de dos fases de autorización seguida de captura es estándar en el procesamiento de tarjetas de crédito y permite a los comerciantes verificar fondos antes de completar la transacción.
Idempotencia en Pagos
La idempotencia es el concepto más importante en el diseño de sistemas de pagos. Los fallos de red, timeouts y reintentos significan que la misma solicitud puede llegar múltiples veces. Sin idempotencia, los clientes pueden ser cobrados dos veces.
Regla Crítica
Cada API de pago debe aceptar una clave de idempotencia. Si una solicitud con la misma clave llega nuevamente, el sistema debe retornar el resultado original sin procesar el pago otra vez. Almacena la clave de idempotencia junto con el registro de pago y verifícala antes de que comience cualquier procesamiento.
async function handlePaymentWithIdempotency(
idempotencyKey: string,
request: PaymentRequest
): Promise<PaymentRecord> {
return await withDistributedLock(`payment:${idempotencyKey}`, async () => {
const existing = await db.payments.findOne({ idempotencyKey });
if (existing) {
return existing; // Return cached result - do NOT reprocess
}
const result = await processPayment(request);
return result;
});
}
// Client-side: generate idempotency key before sending
function generateIdempotencyKey(
userId: string,
orderId: string
): string {
return crypto
.createHash("sha256")
.update(`${userId}:${orderId}:${Date.now()}`)
.digest("hex");
}Integración con Pasarela de Pagos
Las pasarelas de pago (Stripe, Adyen, Braintree) abstraen la complejidad de comunicarse con redes de tarjetas y bancos. Tu sistema debe soportar múltiples pasarelas para redundancia y optimización de costos.
| Componente | Rol | Ejemplos |
|---|---|---|
| Pasarela de Pagos | Enruta transacciones a adquirentes | Stripe, Adyen, Braintree |
| Banco Adquirente | Procesa transacciones en nombre de comerciantes | Chase, Wells Fargo |
| Red de Tarjetas | Facilita la comunicación entre adquirentes y emisores | Visa, Mastercard, Amex |
| Banco Emisor | Banco del cliente que emitió la tarjeta | Capital One, Citi |
// Gateway abstraction for multi-gateway support
interface PaymentGateway {
name: string;
authorize(payment: PaymentRecord): Promise<GatewayResponse>;
capture(transactionId: string, amount: number): Promise<GatewayResponse>;
refund(transactionId: string, amount: number): Promise<GatewayResponse>;
void(transactionId: string): Promise<GatewayResponse>;
}
class GatewayRouter {
private gateways: Map<string, PaymentGateway> = new Map();
private routingRules: RoutingRule[];
selectGateway(payment: PaymentRecord): PaymentGateway {
for (const rule of this.routingRules) {
if (rule.matches(payment)) {
const gw = this.gateways.get(rule.gatewayName);
if (gw && this.isHealthy(rule.gatewayName)) return gw;
}
}
return this.gateways.get("stripe")!;
}
}Manejo de Fallos y Reintentos
El procesamiento de pagos involucra múltiples servicios externos, cada uno de los cuales puede fallar. El sistema debe manejar fallos elegantemente sin cobrar dos veces a los clientes.
- Manejo de timeouts: Si una llamada a la pasarela expira, consulta la pasarela por el estado de la transacción antes de reintentar
- Backoff exponencial: Reintentar llamadas fallidas con delays crecientes (1s, 2s, 4s, 8s)
- Circuit breaker: Si una pasarela falla repetidamente, enrutar tráfico a una pasarela de respaldo
- Transacciones compensatorias: Si la captura falla después de la autorización, anular la autorización
- Cola de mensajes muertos: Mover fallos irresolubles a una cola para revisión manual
Consideraciones de Seguridad (PCI-DSS)
El cumplimiento de PCI-DSS (Payment Card Industry Data Security Standard) es obligatorio para cualquier sistema que maneje datos de tarjetahabientes. El enfoque más común es minimizar tu alcance PCI usando tokenización.
Estrategias de Reducción de Alcance PCI-DSS
- Tokenización: Nunca almacenes números de tarjeta sin procesar. Usa tokens proporcionados por la pasarela
- Cifrado del lado del cliente: Recolecta detalles de tarjeta en el iframe o SDK de la pasarela
- Segmentación de red: Aísla los servicios de pago en una zona de red separada
- Cifrado en reposo: Cifra todos los datos sensibles almacenados en bases de datos
- Registro de accesos: Registra todo acceso a datos de pago con logs de auditoría inmutables
- Gestión de claves: Rota las claves de cifrado regularmente usando un KMS dedicado
Reconciliación
La reconciliación es el proceso de verificar que tus registros internos coinciden con los registros externos (estados de cuenta bancarios, reportes de pasarela). Esto es esencial para detectar discrepancias, fraude y errores del sistema.
interface ReconciliationResult {
matched: number;
mismatched: ReconciliationMismatch[];
missingInternal: string[];
missingExternal: string[];
}
async function reconcile(
date: string
): Promise<ReconciliationResult> {
const internalTxns = await db.payments.find({
date,
status: { $in: ["CAPTURED", "SETTLED", "REFUNDED"] },
});
const gatewayReport = await gateway.getSettlementReport(date);
const result: ReconciliationResult = {
matched: 0,
mismatched: [],
missingInternal: [],
missingExternal: [],
};
const gatewayMap = new Map(
gatewayReport.map((t) => [t.transactionId, t])
);
for (const internal of internalTxns) {
const external = gatewayMap.get(internal.gatewayTransactionId);
if (!external) {
result.missingExternal.push(internal.id);
} else if (internal.amount !== external.amount) {
result.mismatched.push({
internalId: internal.id,
externalId: external.transactionId,
internalAmount: internal.amount,
externalAmount: external.amount,
});
} else {
result.matched++;
}
gatewayMap.delete(internal.gatewayTransactionId);
}
result.missingInternal = Array.from(gatewayMap.keys());
return result;
}Libro Mayor y Contabilidad de Doble Entrada
Un sistema de pagos adecuado usa contabilidad de doble entrada: cada transacción crea al menos dos entradas en el libro mayor que deben balancearse. Esto asegura que el dinero nunca se pierde ni se crea de la nada.
interface LedgerEntry {
id: string;
transactionId: string;
accountId: string;
amount: number; // Positive = debit, Negative = credit
currency: string;
entryType: "DEBIT" | "CREDIT";
createdAt: Date;
}
// For a $100 payment from customer to merchant:
// Customer account: DEBIT -$100 (money leaves customer)
// Merchant account: CREDIT +$97 (merchant receives minus fees)
// Revenue account: CREDIT +$3 (platform fee)
// Sum of all entries = 0 (the fundamental invariant)
async function createLedgerEntries(payment: PaymentRecord): Promise<void> {
const fee = calculatePlatformFee(payment.amount);
const entries: LedgerEntry[] = [
{
id: generateId(),
transactionId: payment.id,
accountId: payment.customerId,
amount: -payment.amount,
currency: payment.currency,
entryType: "DEBIT",
createdAt: new Date(),
},
{
id: generateId(),
transactionId: payment.id,
accountId: payment.merchantId,
amount: payment.amount - fee,
currency: payment.currency,
entryType: "CREDIT",
createdAt: new Date(),
},
{
id: generateId(),
transactionId: payment.id,
accountId: "PLATFORM_REVENUE",
amount: fee,
currency: payment.currency,
entryType: "CREDIT",
createdAt: new Date(),
},
];
// All entries must be inserted atomically
await db.ledger.insertMany(entries, { session: transaction });
}Inmutabilidad del Libro Mayor
Las entradas del libro mayor nunca deben actualizarse ni eliminarse. Para corregir un error, creas una nueva entrada de reversión. Esto asegura una pista de auditoría completa y previene la manipulación. Trata tu libro mayor como un log de solo agregación.