System Design: cómo diseñar un sistema de pagos en una entrevista
Diseñar un sistema de pagos es una de las preguntas más frecuentes —y más temidas— en entrevistas de system design para roles senior y staff. No porque sea imposible, sino porque requiere combinar criterios de correctitud (idempotencia, consistencia), rendimiento (latencia sub-segundo, throughput alto), regulación (PCI DSS, SCA) y operaciones (reconciliación, monitoreo).
Esta guía te lleva paso a paso, con el mismo ritmo que debés usar en la entrevista: primero clarificás, después modelás, después profundizás. Al final encontrás un diagrama de arquitectura completo y los errores que más eliminan candidatos.
Parte 1 — Antes de dibujar: clarificación de requisitos
Nunca arranques a dibujar cuadros hasta entender qué sistema estás diseñando. En pagos hay diferencias enormes entre un sistema de pagos entre pares (P2P), una pasarela de e-commerce, un procesador interno de nómina o un exchange de criptomonedas.
Pregunta 1: ¿Qué tipo de sistema de pagos te piden diseñar?
Por qué importa: "sistema de pagos" puede significar cosas muy distintas. Las respuestas cambian el modelo de datos, la latencia requerida y los actores involucrados.
Ejemplos concretos:
- PSP (Payment Service Provider): intermediario entre el comercio y los bancos adquirentes. Ejemplos: Stripe, dLocal, Mercado Pago.
- Wallet interna: saldo dentro de una app, movimientos entre usuarios (tipo PayPal, Mercado Libre, Rappi).
- Procesador de nómina: pagos en lote, alta consistencia, baja urgencia.
- Pasarela de checkout: captura de tarjeta y tokenización para un e-commerce.
En la entrevista, si no te especifican, preguntá: *"¿Estamos diseñando la pasarela que expone el comercio, el procesador interno de cuentas, o ambos?"*
Pregunta 2: ¿Cuál es el volumen esperado de transacciones por segundo (TPS)?
Por qué importa: define si necesitás sharding, colas asíncronas y qué motor de base de datos aplica.
Referencias reales:
- Visa: ~1.700 TPS promedio, pico de ~65.000 TPS.
- Stripe: no publicado, pero maneja millones de negocios globales.
- Mercado Pago: pico en Hot Sale Argentina > 10.000 TPS.
Para una entrevista, un número razonable es: 500–5.000 TPS para un sistema regional. Si el entrevistador dice "escala global", diseñás para 50.000 TPS.
Pregunta 3: ¿Cuál es la latencia aceptable para confirmar un pago?
Por qué importa: pagos síncronos (respuesta en <500ms) vs asíncronos (respuesta en segundos o minutos) cambian todo el flujo.
- Tarjeta de crédito: el usuario espera una respuesta en <2 segundos antes de dudar de que algo salió mal.
- Transferencia bancaria (ACH, CBU): puede tomar minutos o días.
- Crypto: confirmaciones pueden tomar 10–60 minutos.
Clarificá: *"¿El pago necesita confirmarse sincrónicamente antes de que el usuario vea 'pago aprobado', o podemos usar un flujo asíncrono con notificación posterior?"*
Pregunta 4: ¿Qué monedas y regiones hay que soportar?
Por qué importa: determina complejidad de conversión, PSPs locales, y regulaciones.
- Moneda única: modelo simple, sin conversión.
- Multi-moneda: necesitás tipo de cambio (¿fijado al momento del pago? ¿al momento del settlement?), y manejar riesgo FX.
- LATAM específicamente: BRL, ARS, COP, MXN, cada uno con su PSP local preferido (Mercado Pago, dLocal, OXXO, Rappi Pay).
Pregunta 5: ¿Qué operaciones básicas tiene que soportar el sistema?
Lista mínima que debés cubrir:
charge/authorize + capturerefundchargebackpayout(pagar al comercio o al usuario)dispute
Pregunta 6: ¿Qué nivel de disponibilidad se espera?
SLA típicos:
- 99.9% = ~8.7h de downtime al año.
- 99.99% = ~52 minutos al año.
- 99.999% = ~5 minutos al año.
Para sistemas de pagos productivos, el target mínimo es 99.99%. Esto implica diseñar con redundancia, failover automático y circuit breakers.
Parte 2 — Modelo de datos
Un modelo de datos mal pensado es el error más difícil de corregir en producción. En pagos, hay tres abstracciones centrales.
Pregunta 7: ¿Cuál es la diferencia entre una cuenta, una transacción y un ledger?
- Cuenta (Account): representa a un actor (usuario, comercio, sistema). Tiene un balance calculado o materializado.
- Transacción (Transaction): evento atómico de movimiento de dinero. Es inmutable: nunca se modifica, solo se cancela con otra transacción inversa.
- Ledger (libro mayor): registro de todos los asientos contables. Implementa contabilidad de doble entrada.
-- Tabla de cuentas
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id),
currency CHAR(3) NOT NULL, -- ISO 4217: ARS, USD, BRL
type TEXT NOT NULL, -- 'user', 'merchant', 'system'
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabla de transacciones (inmutable)
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'processing' | 'completed' | 'failed' | 'reversed'
amount BIGINT NOT NULL, -- en centavos/unidad mínima
currency CHAR(3) NOT NULL,
from_account_id UUID REFERENCES accounts(id),
to_account_id UUID REFERENCES accounts(id),
type TEXT NOT NULL, -- 'charge' | 'refund' | 'payout' | 'fee'
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Ledger (doble entrada — jamás se modifica, solo inserta)
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES transactions(id),
account_id UUID NOT NULL REFERENCES accounts(id),
amount BIGINT NOT NULL, -- positivo = crédito, negativo = débito
balance_after BIGINT NOT NULL, -- snapshot del balance en ese momento
created_at TIMESTAMPTZ DEFAULT NOW()
);Pregunta 8: ¿Por qué el monto se guarda en centavos (enteros) y no en decimales?
Porque los tipos FLOAT y DOUBLE tienen errores de redondeo en base 2. 0.1 + 0.2 en floats no es exactamente 0.3. En pagos eso es inaceptable.
Regla: siempre almacená en la unidad mínima de la moneda:
- USD, ARS, EUR: centavos (1 USD = 100 unidades).
- JPY: la unidad mínima es el yen (sin decimales).
- BTC: satoshis (1 BTC = 100.000.000 satoshis).
# Mal — nunca hagas esto
amount = 10.99 # float
# Bien
amount_cents = 1099 # int, siempre
# Conversión al mostrar
def format_amount(cents: int, currency: str) -> str:
if currency in ("JPY", "KRW"):
return f"{cents}"
return f"{cents / 100:.2f}"Pregunta 9: ¿Qué es la contabilidad de doble entrada y por qué es obligatoria en pagos?
En contabilidad de doble entrada, cada movimiento de dinero genera dos asientos: uno que debita una cuenta y otro que acredita otra. La suma de todos los asientos siempre es cero. Esto permite detectar inconsistencias: si los debits no igualan los crédits, hay un bug o fraude.
-- Un pago de $100 del usuario A al comercio B
-- genera dos asientos:
INSERT INTO ledger_entries (transaction_id, account_id, amount, balance_after)
VALUES
(txn_id, account_user_a, -10000, 50000), -- débito: le salen $100
(txn_id, account_merchant_b, 10000, 20000); -- crédito: le entran $100
-- Invariante: SUM(amount) OVER (txn_id) = 0
SELECT SUM(amount) FROM ledger_entries WHERE transaction_id = txn_id;
-- debe retornar 0Parte 3 — Idempotencia
Esta es la parte donde más candidatos se caen. La idempotencia no es un nice-to-have: es el mecanismo que evita que le cobrés dos veces a un usuario porque la red falló y el cliente reintentó.
Pregunta 10: ¿Qué es idempotencia en el contexto de pagos?
Una operación es idempotente si ejecutarla una vez o N veces produce el mismo resultado. En pagos: si el cliente envía la misma solicitud de cobro dos veces (por timeout, retry, bug), el sistema debe ejecutar el cobro una sola vez.
Pregunta 11: ¿Cómo implementás idempotencia en una API de pagos?
El mecanismo estándar es una idempotency key: un UUID que el cliente genera y envía en el header. El servidor lo persiste junto con la respuesta. Si recibe la misma key otra vez, devuelve la respuesta cacheada sin ejecutar nada.
# Cliente: genera la key ANTES de enviar
import uuid
idempotency_key = str(uuid.uuid4()) # se genera una sola vez para este intento
response = requests.post(
"https://api.payments.com/v1/charges",
headers={"Idempotency-Key": idempotency_key},
json={"amount": 5000, "currency": "ARS", "source": "tok_123"}
)# Servidor: lógica de idempotencia
from db import get_idempotency_record, save_idempotency_record
from fastapi import Header, HTTPException
async def create_charge(
payload: ChargeRequest,
idempotency_key: str = Header(...)
):
# 1. Buscar si ya existe
existing = await get_idempotency_record(idempotency_key)
if existing:
return existing.response # devolvés la misma respuesta, sin reejecutar
# 2. Ejecutar el cobro (dentro de una transacción DB)
async with db.transaction():
charge = await process_charge(payload)
# 3. Guardar key + respuesta (atómicamente con el cobro)
await save_idempotency_record(
key=idempotency_key,
response=charge.to_dict(),
expires_at=datetime.now() + timedelta(days=7)
)
return charge-- Tabla de idempotencia
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
response JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- El UNIQUE en key garantiza que dos requests concurrentes
-- con la misma key no creen dos registrosPregunta 12: ¿Qué pasa si dos requests concurrentes usan la misma idempotency key?
Es el caso de race condition. Se resuelve con un lock a nivel base de datos o con INSERT ... ON CONFLICT DO NOTHING:
-- INSERT atómico: si ya existe, no hace nada
INSERT INTO idempotency_records (key, response, expires_at)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO NOTHING
RETURNING *;
-- Si RETURNING no devuelve filas, es que ya existía → fetch y devolvéParte 4 — Flujo de una transacción
Pregunta 13: ¿Cuál es el flujo completo de un pago con tarjeta de crédito?
Cliente → API Gateway → Payment Service
│
┌─────────▼──────────┐
│ Validar & crear │
│ transacción │
│ (status=pending) │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Publicar evento │
│ en Kafka │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Payment Worker │
│ consume el evento │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Llamar al PSP │
│ (Stripe / dLocal) │
└──┬──────────────┬──┘
│ │
Éxito │ Fallo│
│ │
status= │ status= │
completed │ failed │
│ │
┌──▼──────────────▼──┐
│ Actualizar ledger │
│ Notificar webhook │
└────────────────────┘Pregunta 14: ¿Cuál es la diferencia entre `authorize` y `capture`?
- Authorize: reserva los fondos en la tarjeta del usuario. El banco "bloquea" el monto. El dinero todavía no se mueve.
- Capture: ejecuta el movimiento real. Generalmente hasta 7 días después de la autorización.
Esto es útil en e-commerce: autorizás cuando el usuario confirma la compra, capturás cuando el producto se despacha.
# Paso 1: autorizar (reservar fondos)
auth = stripe.PaymentIntent.create(
amount=5000,
currency="ars",
capture_method="manual", # no captura automáticamente
payment_method="pm_card_visa",
confirm=True
)
# auth.status == "requires_capture"
# Paso 2: capturar (cuando despachás el pedido)
stripe.PaymentIntent.capture(auth.id)Parte 5 — Colas asíncronas con Kafka
Pregunta 15: ¿Por qué usás Kafka en un sistema de pagos y no llamadas síncronas al PSP?
Tres razones principales:
- 1Desacoplamiento de latencia: la llamada al PSP puede tardar 200ms–2s. Si la hacés síncrona, bloqueás el hilo del servidor. Con Kafka, el API responde en <50ms y el worker procesa en background.
- 2Reintentos con backoff: si el PSP falla, Kafka retiene el mensaje. Podés reintentar sin perder la transacción.
- 3Throughput: Kafka maneja millones de mensajes por segundo. El PSP tiene sus propios rate limits; Kafka actúa como buffer.
Pregunta 16: ¿Cómo estructurás los topics de Kafka para pagos?
payments.transactions.created → payment-workers (procesan con PSP)
payments.transactions.completed → ledger-workers (asientan en ledger)
payments.transactions.failed → retry-workers / alerting
payments.webhooks.outbound → webhook-dispatcher (notifica al comercio)
payments.reconciliation.daily → reconciliation-worker# Productor: cuando se crea la transacción
from kafka import KafkaProducer
import json
producer = KafkaProducer(
bootstrap_servers="kafka:9092",
value_serializer=lambda v: json.dumps(v).encode("utf-8")
)
producer.send(
"payments.transactions.created",
key=transaction_id.encode(), # key = partition determinista por txn
value={
"transaction_id": str(transaction_id),
"amount": 5000,
"currency": "ARS",
"idempotency_key": idempotency_key,
"payment_method_id": payment_method_id,
"created_at": created_at.isoformat()
}
)# Consumidor: worker que llama al PSP
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"payments.transactions.created",
group_id="payment-workers",
bootstrap_servers="kafka:9092",
enable_auto_commit=False # commit manual después de procesar
)
for message in consumer:
txn = json.loads(message.value)
try:
result = call_psp(txn)
update_transaction_status(txn["transaction_id"], "completed", result)
consumer.commit() # solo comiteás si todo salió bien
except PSPError as e:
if e.retryable:
# No commitear → Kafka va a redeliverar
pass
else:
update_transaction_status(txn["transaction_id"], "failed", str(e))
consumer.commit()Pregunta 17: ¿Qué pasa si el worker procesa el mensaje pero falla antes de hacer commit?
Kafka va a redeliverar el mensaje. Por eso el procesamiento debe ser idempotente: si el PSP ya cobró y el worker recibe el mismo mensaje de nuevo, tiene que detectar que ya existe y no cobrar dos veces.
def process_transaction(txn: dict):
# Verificar si ya fue procesada (idempotencia a nivel worker)
existing = db.query(
"SELECT status FROM transactions WHERE id = %s",
txn["transaction_id"]
)
if existing.status in ("completed", "failed"):
return # ya procesada, ignorar
# Procesar solo si está en pending
result = psp_client.charge(
amount=txn["amount"],
idempotency_key=txn["idempotency_key"] # pasamos la key al PSP también
)
update_transaction_status(txn["transaction_id"], "completed", result)Parte 6 — Consistencia
Pregunta 18: ¿Cuándo usás consistencia eventual vs consistencia fuerte en pagos?
Esta es una pregunta de trade-off que los entrevistadores hacen para ver si entendés los límites del sistema.
Consistencia fuerte (Strong Consistency):
- Requerida cuando el dato afecta directamente la autorización del pago.
- Ejemplo: el balance disponible del usuario. Si permitís consistencia eventual, podés autorizar pagos por más del saldo disponible (overdraft no intencional).
- Implementación: transacciones ACID en PostgreSQL con
SELECT ... FOR UPDATE.
Consistencia eventual (Eventual Consistency):
- Aceptable para datos secundarios o de reporting.
- Ejemplo: el dashboard de analytics del comercio, los totales del día, las notificaciones de webhook.
- Implementación: Kafka + procesamiento asíncrono + vistas materializadas.
-- Ejemplo de débito con consistencia fuerte
-- Usa FOR UPDATE para lockear la fila durante la transacción
BEGIN;
SELECT balance
FROM accounts
WHERE id = $1
FOR UPDATE; -- lock exclusivo
-- Verificar saldo suficiente
-- Si balance < amount → ROLLBACK y retornar error
UPDATE accounts
SET balance = balance - $amount
WHERE id = $1;
INSERT INTO ledger_entries (transaction_id, account_id, amount, balance_after)
VALUES ($txn_id, $1, -$amount, balance - $amount);
COMMIT;Pregunta 19: ¿Qué es el problema del "double-spend" y cómo lo prevenís?
El double-spend ocurre cuando el mismo saldo se usa dos veces en transacciones concurrentes. Sin locking, dos transacciones simultáneas pueden leer el mismo balance y ambas aprobar el gasto.
-- Sin protección (MAL):
-- Thread A lee balance = 100
-- Thread B lee balance = 100
-- Thread A escribe balance = 0 (cobró 100)
-- Thread B escribe balance = 0 (cobró 100 también — ¡pero ya no había!)
-- Con SELECT FOR UPDATE (BIEN):
BEGIN;
SELECT balance FROM accounts WHERE id = $user_id FOR UPDATE;
-- Thread B queda bloqueado hasta que Thread A haga COMMIT o ROLLBACK
UPDATE accounts SET balance = balance - $amount
WHERE id = $user_id AND balance >= $amount;
-- Verificar affected rows = 1, si no → ROLLBACK
COMMIT;Parte 7 — Integración con PSPs
Pregunta 20: ¿Cómo diseñás la capa de integración con múltiples PSPs?
Definís una abstracción (PaymentGateway) y una implementación por PSP. El selector elige el PSP según moneda, país, método de pago o fallback.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class ChargeResult:
psp_transaction_id: str
status: str # 'succeeded' | 'failed' | 'pending'
error_code: str | None = None
class PaymentGateway(ABC):
@abstractmethod
async def charge(
self,
amount: int,
currency: str,
payment_method_token: str,
idempotency_key: str
) -> ChargeResult:
...
@abstractmethod
async def refund(self, psp_transaction_id: str, amount: int) -> ChargeResult:
...
class StripeGateway(PaymentGateway):
async def charge(self, amount, currency, payment_method_token, idempotency_key):
import stripe
intent = stripe.PaymentIntent.create(
amount=amount,
currency=currency.lower(),
payment_method=payment_method_token,
confirm=True,
idempotency_key=idempotency_key
)
return ChargeResult(
psp_transaction_id=intent.id,
status="succeeded" if intent.status == "succeeded" else "failed"
)
class DLocalGateway(PaymentGateway):
"""Para LATAM: Brasil, México, Argentina, Colombia, etc."""
async def charge(self, amount, currency, payment_method_token, idempotency_key):
# dLocal maneja métodos locales: OXXO, Boleto, PSE, Rapipago
response = await self.client.post(
"/secure_payments",
json={
"amount": amount / 100, # dLocal usa decimales
"currency": currency,
"payment_method_id": payment_method_token,
"order_id": idempotency_key
}
)
return ChargeResult(
psp_transaction_id=response["id"],
status=response["status"]
)
class MercadoPagoGateway(PaymentGateway):
"""Para Argentina y Brasil principalmente"""
async def charge(self, amount, currency, payment_method_token, idempotency_key):
response = await self.client.post(
"/v1/payments",
headers={"X-Idempotency-Key": idempotency_key},
json={
"transaction_amount": amount / 100,
"token": payment_method_token,
"installments": 1
}
)
return ChargeResult(
psp_transaction_id=str(response["id"]),
status="succeeded" if response["status"] == "approved" else "failed"
)
# Selector de PSP
class PSPRouter:
def select(self, currency: str, country: str, method: str) -> PaymentGateway:
if country in ("AR", "BR", "MX", "CO") and method in ("oxxo", "boleto", "pse"):
return DLocalGateway()
if country == "AR" and method == "mercadopago":
return MercadoPagoGateway()
return StripeGateway() # fallback para tarjetas internacionalesPregunta 21: ¿Cómo manejás los webhooks que recibís del PSP?
Los PSPs notifican eventos asíncronamente (pago completado, disputa abierta, chargeback). Estas notificaciones pueden llegar fuera de orden o duplicadas.
# Endpoint de webhook
from fastapi import Request, HTTPException
import hmac, hashlib
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
# 1. Verificar firma (evitar webhooks falsos)
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Invalid signature")
# 2. Idempotencia: ignorar eventos ya procesados
if await event_already_processed(event["id"]):
return {"status": "already_processed"}
# 3. Publicar en Kafka para procesamiento asíncrono
# (no procesamos directamente en el endpoint — queremos responder rápido al PSP)
await kafka_producer.send(
"payments.psp.events",
key=event["id"].encode(),
value={
"event_id": event["id"],
"type": event["type"],
"data": event["data"],
"received_at": datetime.now().isoformat()
}
)
# 4. Responder 200 rápido (Stripe reintenta si no recibe 200 en <30s)
return {"status": "queued"}Parte 8 — Reintentos y manejo de fallos
Pregunta 22: ¿Cómo diseñás la lógica de reintentos para fallos del PSP?
Diferenciá entre errores retryable y no-retryable antes de reintentar ciegamente.
class PSPError(Exception):
def __init__(self, code: str, message: str, retryable: bool):
self.code = code
self.retryable = retryable
super().__init__(message)
# Errores NO retryable (reintentar no va a ayudar)
NON_RETRYABLE_CODES = {
"card_declined", # el banco rechazó la tarjeta
"insufficient_funds", # no tiene saldo
"card_expired", # tarjeta vencida
"invalid_card_number", # número inválido
"do_not_honor", # el banco dice que no
}
# Errores retryable (pueden resolverse solos)
RETRYABLE_CODES = {
"rate_limited", # PSP con rate limit
"timeout", # timeout de red
"service_unavailable", # PSP caído transitoriamente
"processing_error", # error interno del PSP
}
async def charge_with_retry(
gateway: PaymentGateway,
payload: dict,
max_attempts: int = 3
) -> ChargeResult:
base_delay = 1.0 # segundos
for attempt in range(max_attempts):
try:
return await gateway.charge(**payload)
except PSPError as e:
if not e.retryable:
raise # no reintentar
if attempt == max_attempts - 1:
raise # agotamos reintentos
# Exponential backoff con jitter
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
await asyncio.sleep(delay)Pregunta 23: ¿Qué es el patrón Outbox y por qué es importante en pagos?
El problema del Outbox resuelve el caso donde necesitás actualizar la DB y publicar en Kafka de forma atómica. Sin Outbox, podés tener:
- Guardar en DB ✓ → publicar en Kafka ✗ → transacción queda en pending para siempre
- Guardar en DB ✗ → publicar en Kafka ✓ → hay un evento en Kafka para una transacción que no existe
-- Tabla de outbox (misma base de datos que las transacciones)
CREATE TABLE outbox_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic TEXT NOT NULL,
key TEXT,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ -- null = pendiente de publicar
);# En la misma transacción de DB, insertás en outbox
async def create_charge(payload: dict):
async with db.transaction():
# 1. Crear la transacción
txn = await db.execute(
"INSERT INTO transactions (...) VALUES (...) RETURNING *",
payload
)
# 2. Insertar en outbox (mismo TX — o ambos persisten o ninguno)
await db.execute(
"INSERT INTO outbox_messages (topic, key, payload) VALUES ($1, $2, $3)",
"payments.transactions.created",
str(txn.id),
txn.to_dict()
)
# Después del commit, un poller lee el outbox y publica en Kafka
# Poller que lee outbox y publica (corre en background)
async def outbox_poller():
while True:
messages = await db.fetchall(
"SELECT * FROM outbox_messages WHERE published_at IS NULL LIMIT 100"
)
for msg in messages:
await kafka_producer.send(msg.topic, key=msg.key, value=msg.payload)
await db.execute(
"UPDATE outbox_messages SET published_at = NOW() WHERE id = $1",
msg.id
)
await asyncio.sleep(0.1)Parte 9 — Reconciliación
Pregunta 24: ¿Qué es la reconciliación y cuándo la necesitás?
La reconciliación es el proceso de comparar los registros internos del sistema con los del PSP para detectar discrepancias. Es obligatoria en cualquier sistema de pagos productivo.
Escenarios que la reconciliación detecta:
- Pago que el PSP marcó como completado pero que en el sistema interno quedó en
pending. - Refund procesado por el PSP sin que el sistema lo haya solicitado.
- Fee cobrado por el PSP que no coincide con lo esperado.
# Reconciliation worker (corre una vez al día)
async def daily_reconciliation(date: date):
# 1. Obtener todos los pagos del día desde el PSP
psp_transactions = await stripe_client.list_charges(
created_from=datetime.combine(date, time.min),
created_to=datetime.combine(date, time.max)
)
# 2. Obtener los registros internos del mismo período
internal_transactions = await db.fetchall(
"""SELECT id, psp_transaction_id, amount, status
FROM transactions
WHERE created_at::date = $1""",
date
)
internal_map = {t.psp_transaction_id: t for t in internal_transactions}
discrepancies = []
for psp_txn in psp_transactions:
internal = internal_map.get(psp_txn.id)
if not internal:
discrepancies.append({
"type": "missing_internal",
"psp_transaction_id": psp_txn.id,
"amount": psp_txn.amount
})
continue
if internal.amount != psp_txn.amount:
discrepancies.append({
"type": "amount_mismatch",
"internal_id": str(internal.id),
"psp_transaction_id": psp_txn.id,
"internal_amount": internal.amount,
"psp_amount": psp_txn.amount
})
if internal.status == "pending" and psp_txn.status == "succeeded":
discrepancies.append({
"type": "status_mismatch",
"internal_id": str(internal.id),
"action": "mark_completed"
})
# 3. Persistir discrepancias para revisión manual o auto-resolución
if discrepancies:
await save_reconciliation_report(date, discrepancies)
await alert_ops_team(f"{len(discrepancies)} discrepancias en {date}")Parte 10 — Seguridad y PCI DSS
Pregunta 25: ¿Qué es PCI DSS y cómo afecta el diseño?
PCI DSS (Payment Card Industry Data Security Standard) es el conjunto de requisitos de seguridad para cualquier sistema que procese, almacene o transmita datos de tarjetas de crédito.
Niveles de PCI DSS:
- SAQ A: el sistema nunca toca datos de tarjetas (usa iframes del PSP). El más fácil.
- SAQ D: el sistema toca datos de tarjetas. Requiere auditoría anual por un QSA certificado.
Diseño para minimizar el scope PCI:
El objetivo es que tu sistema nunca toque el PAN (Primary Account Number — el número de la tarjeta). Para eso:
[Cliente]
│ (tarjeta ingresada en iframe del PSP)
│
[PSP (Stripe/dLocal)]
│ (devuelve un token, ej: "tok_1AbcDEF...")
│
[Tu sistema]
│ (solo almacena el token, nunca el PAN)
▼
[Base de datos — solo tokens]# NUNCA hagas esto
payment_method = {
"card_number": "4242424242424242", # ¡NUNCA almacenes esto!
"cvv": "123", # ¡NUNCA!
"expiry": "12/26" # ¡NUNCA!
}
# Siempre trabaja con tokens del PSP
payment_method = {
"token": "pm_1AbcDEF23GhIJKL", # token opaco del PSP — esto sí
"last4": "4242", # solo los últimos 4 para display
"brand": "visa",
"exp_month": 12,
"exp_year": 2026
}Pregunta 26: ¿Qué otras medidas de seguridad implementás?
- 1Encriptación en tránsito: TLS 1.2+ en todos los endpoints.
- 2Encriptación en reposo: datos sensibles en DB con AES-256 (manejado por el cloud provider o Vault de HashiCorp).
- 3Autenticación de webhooks: siempre verificar la firma del PSP antes de procesar.
- 4Rate limiting: limitar intentos de pago por usuario/IP para prevenir carding attacks.
- 5Fraud detection: reglas básicas (monto inusual, múltiples países en corto tiempo) o integración con herramientas como Stripe Radar, Sift, Kount.
# Rate limiting básico para prevenir ataques de carding
from redis import Redis
redis = Redis()
async def check_rate_limit(user_id: str, ip: str) -> bool:
# Máximo 5 intentos de pago por IP por hora
ip_key = f"payment_attempts:ip:{ip}"
ip_count = redis.incr(ip_key)
if ip_count == 1:
redis.expire(ip_key, 3600)
if ip_count > 5:
return False # bloquear
# Máximo 3 tarjetas distintas por usuario por día
card_key = f"payment_attempts:user:{user_id}:date:{date.today()}"
user_count = redis.incr(card_key)
if user_count == 1:
redis.expire(card_key, 86400)
if user_count > 10:
return False
return TrueParte 11 — Diagrama de arquitectura completo
Pregunta 27: ¿Podés dibujar el diagrama de arquitectura del sistema?
┌──────────────────────────────────────────────────────────┐
│ CLIENTE / SDK │
│ (iframe PSP para captura de tarjeta — fuera del scope) │
└────────────────────────┬─────────────────────────────────┘
│ HTTPS + Idempotency-Key
▼
┌──────────────────────────────────────────────────────────┐
│ API GATEWAY / BFF │
│ Rate Limiting · Auth (JWT) · TLS termination │
└────────────────────────┬─────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Payments │ │ Accounts │ │ Webhooks │
│ Service │ │ Service │ │ Service │
└──────┬──────┘ └──────┬──────┘ └──────┬───────┘
│ │ │
└────────────────┼────────────────┘
│
▼
┌────────────────────────────────┐
│ Kafka Cluster │
│ payments.transactions.created │
│ payments.transactions.completed│
│ payments.psp.events │
│ payments.webhooks.outbound │
└────────────────┬───────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ Payment │ │ Ledger │ │ Webhook │
│ Workers │ │ Workers │ │ Dispatcher │
│ (x10) │ │ (x5) │ │ (x3) │
└──────┬───────┘ └──────┬─────┘ └──────────────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ PSP Router │
│ ┌─────────┐ ┌────────┐ ┌─────────┐ │
│ │ Stripe │ │ dLocal │ │MercPago │ │
│ └─────────┘ └────────┘ └─────────┘ │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Databases / Storage │
│ ┌────────────────┐ ┌─────────────┐ │
│ │ PostgreSQL (RW)│ │ Redis Cache │ │
│ │ Primary + 2 │ │ (idempotency│ │
│ │ Read Replicas │ │ / sessions)│ │
│ └────────────────┘ └─────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ ClickHouse / BigQuery (analytics│ │
│ │ + reconciliation reports) │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Monitoring & Alerting │
│ Prometheus · Grafana · PagerDuty │
│ Distributed Tracing (Jaeger/OTEL) │
└──────────────────────────────────────┘Parte 12 — Monitoreo y escalabilidad
Pregunta 28: ¿Qué métricas monitoreas en un sistema de pagos?
Métricas de negocio (las más importantes):
payment_success_rate— % de pagos que terminan encompleted. Si baja del 95%, hay un problema.payment_volume_per_minute— volumen en dinero. Pico inusual puede ser fraude.average_payment_latency_p99— percentil 99 de latencia. Los outliers importan.chargeback_rate— % de pagos revertidos. Umbral de Visa/Mastercard: <1%.
Métricas técnicas:
kafka_consumer_lag— si el lag crece, los workers no alcanzan a procesar.db_lock_wait_time— si aumenta, hay contención en las transacciones.psp_error_rate_by_code— qué tipos de error vienen del PSP.
# Instrumentación con Prometheus
from prometheus_client import Counter, Histogram, Gauge
payment_attempts = Counter(
"payment_attempts_total",
"Total de intentos de pago",
["psp", "currency", "status"]
)
payment_latency = Histogram(
"payment_processing_seconds",
"Latencia de procesamiento de pagos",
["psp"],
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
kafka_consumer_lag = Gauge(
"kafka_consumer_lag",
"Mensajes pendientes en Kafka",
["topic", "consumer_group"]
)
# Uso en el código
async def process_payment(txn: dict):
psp_name = get_psp_for_transaction(txn)
with payment_latency.labels(psp=psp_name).time():
try:
result = await charge(txn)
payment_attempts.labels(
psp=psp_name,
currency=txn["currency"],
status="success"
).inc()
except PSPError as e:
payment_attempts.labels(
psp=psp_name,
currency=txn["currency"],
status=e.code
).inc()
raisePregunta 29: ¿Cómo escalás el sistema para manejar picos de tráfico?
Escalado horizontal de los workers:
- Los payment workers son stateless, pueden escalar a N instancias.
- Kafka redistribuye las particiones automáticamente.
- Regla práctica: 1 worker por partición de Kafka.
Sharding de la base de datos:
- Sharding por
account_ido por región. - Cada shard es un PostgreSQL independiente con sus réplicas.
# Selección de shard por account_id
def get_shard_for_account(account_id: str) -> str:
# Consistent hashing o módulo simple
shard_index = int(account_id.replace("-", ""), 16) % NUM_SHARDS
return f"db-shard-{shard_index}"Caching:
- Datos de cuenta leídos frecuentemente → Redis con TTL corto (30s).
- Resultados de idempotencia → Redis con TTL de 7 días.
Pregunta 30: ¿Cómo manejás el settlement y los payouts al comercio?
El settlement es el proceso por el cual el PSP transfiere el dinero capturado a la cuenta bancaria del comercio. Normalmente ocurre en T+1 o T+2 (un o dos días hábiles después).
# Job de payout nocturno
async def process_daily_settlements(settlement_date: date):
# 1. Calcular el monto neto por comercio
# (ventas - refunds - fees del día anterior)
settlements = await db.fetchall("""
SELECT
merchant_id,
SUM(CASE WHEN type = 'charge' THEN amount ELSE 0 END) as gross_volume,
SUM(CASE WHEN type = 'refund' THEN amount ELSE 0 END) as refunds,
SUM(CASE WHEN type = 'fee' THEN amount ELSE 0 END) as fees
FROM transactions
WHERE created_at::date = $1 AND status = 'completed'
GROUP BY merchant_id
""", settlement_date - timedelta(days=1))
for s in settlements:
net_amount = s.gross_volume - s.refunds - s.fees
if net_amount > 0:
await create_payout(
merchant_id=s.merchant_id,
amount=net_amount,
settlement_date=settlement_date
)Parte 13 — Preguntas adicionales de profundidad
Pregunta 31: ¿Cómo implementás chargebacks?
Un chargeback es una reversión iniciada por el banco del usuario, no por el comercio. El dinero se devuelve al usuario y el comercio pierde el monto más una penalidad.
El sistema debe:
- 1Recibir el webhook del PSP notificando la disputa.
- 2Suspender el monto del balance del comercio (no está disponible para payout).
- 3Notificar al comercio para que presente evidencia.
- 4Registrar el resultado (ganó/perdió la disputa).
- 5Ajustar el balance según el resultado.
Pregunta 32: ¿Qué diferencia hay entre un refund y un chargeback?
| | Refund | Chargeback |
|---|---|---|
| Iniciado por | El comercio | El banco/usuario |
| Tiempo | Inmediato | Puede tardar semanas |
| Penalidad al comercio | Ninguna | $15–$100 por disputa |
| Controlado por | El comercio | El banco |
| Reversible | No | Sí (el comercio puede presentar evidencia) |
Pregunta 33: ¿Cómo diseñás el manejo de multi-moneda?
@dataclass
class ExchangeRate:
from_currency: str
to_currency: str
rate: Decimal
fetched_at: datetime
source: str # 'fixer.io' | 'xe.com' | 'central_bank'
# Siempre almacenar el tipo de cambio en el momento del pago
async def create_cross_currency_charge(
amount: int, # en moneda del usuario
user_currency: str, # ej: "ARS"
settlement_currency: str # ej: "USD" — en qué moneda se asienta
) -> dict:
rate = await get_exchange_rate(user_currency, settlement_currency)
# Convertir
settled_amount = int(amount / rate.rate) # en settlement_currency
# Guardar ambos montos y el rate — crucial para auditoría
return {
"charged_amount": amount,
"charged_currency": user_currency,
"settled_amount": settled_amount,
"settled_currency": settlement_currency,
"exchange_rate": float(rate.rate),
"exchange_rate_at": rate.fetched_at.isoformat()
}Pregunta 34: ¿Qué pasa si el PSP devuelve un estado ambiguo?
A veces el PSP devuelve status: "pending" o directamente un timeout sin respuesta. Esto se llama "estado desconocido" y es uno de los casos más delicados.
Estrategia:
- 1Guardar el estado como
processing(distinto dependingycompleted). - 2Publicar un evento para que un worker consulte el estado al PSP después de N segundos.
- 3Si después de K intentos sigue ambiguo, escalar a revisión manual.
async def handle_unknown_psp_status(transaction_id: str):
"""Consulta el estado real de la transacción al PSP"""
txn = await get_transaction(transaction_id)
# Consultar al PSP directamente
psp_status = await psp_client.retrieve_charge(txn.psp_transaction_id)
if psp_status == "succeeded":
await finalize_transaction(transaction_id, "completed")
elif psp_status == "failed":
await finalize_transaction(transaction_id, "failed")
else:
# Todavía pendiente — reencolar para revisar más tarde
delay_seconds = 30 * (2 ** txn.status_check_count) # backoff
await schedule_status_check(transaction_id, delay=delay_seconds)Pregunta 35: ¿Cómo diseñás el sistema para cumplir con SCA (Strong Customer Authentication)?
SCA es un requisito regulatorio europeo (PSD2) que exige autenticación de dos factores para pagos online. En América Latina no es obligatorio aún, pero los PSPs globales lo están adoptando.
El flujo con SCA (3DS2):
- 1Tu sistema crea un PaymentIntent con el PSP.
- 2Si el PSP requiere SCA, devuelve
status: "requires_action". - 3El cliente completa el challenge (ej: código SMS del banco).
- 4El PSP confirma y devuelve
status: "succeeded".
# Manejo de flujo 3DS2
intent = stripe.PaymentIntent.create(
amount=5000,
currency="eur",
payment_method="pm_card_threeDSecure2Required",
confirm=True,
return_url="https://tuapp.com/payment/return"
)
if intent.status == "requires_action":
# Redirigir al usuario para completar el 3DS challenge
redirect_url = intent.next_action.redirect_to_url.url
return {"action_required": True, "redirect_url": redirect_url}
elif intent.status == "succeeded":
return {"success": True}Errores comunes que eliminan candidatos
Error 1: No mencionar idempotencia desde el principio.
Es el error más grave. Un entrevistador senior va a preguntar "¿qué pasa si el cliente reintenta?" y si no tenés una respuesta clara, es señal de que no diseñaste sistemas de pagos reales.
Error 2: Usar floats para montos.
Mencionarlo activamente: "todos los montos se almacenan en la unidad mínima de la moneda como enteros, nunca floats".
Error 3: Ignorar el estado ambiguo del PSP.
La red falla. Los PSPs tienen downtime. ¿Qué pasa si no recibís respuesta? Tener un plan para estados processing y consulta posterior demuestra experiencia real.
Error 4: No distinguir consistencia fuerte de eventual.
Si usás consistencia eventual para el balance, podés tener overdraft. Si usás consistencia fuerte para todo, el sistema no escala. El entrevistador quiere ver que sabés cuándo aplicar cada una.
Error 5: Diseñar sin reconciliación.
La reconciliación no es un detalle operativo, es parte del diseño. Mencionala aunque no tengas tiempo para desarrollarla: "tendría un job nocturno que compara los registros internos con el reporte del PSP".
Error 6: No mencionar PCI DSS.
En cuanto diseñás un sistema que toca pagos con tarjeta, decir "usaría iframes del PSP para nunca tocar el PAN y quedar fuera del scope de PCI DSS" es una señal clara de que sabés del tema.
Tips para la entrevista
- 1Empezá siempre clarificando. Preguntá tipo de sistema, volumen, latencia, monedas antes de dibujar nada.
- 2Nombrá las complejidades aunque no las resolvas completamente. "La reconciliación es compleja; en el tiempo que tenemos la menciono pero nos focalizamos en X" muestra que sabés qué existe.
- 3Usá números reales. "500 TPS es razonable para un sistema regional; Visa maneja 65k en pico" muestra que estudiaste el dominio.
- 4Pensá en voz alta. Los entrevistadores evalúan el proceso tanto como el resultado. Si dudás entre dos enfoques, decilo: "podría usar locks a nivel DB o consistencia eventual; elijo locks porque el monto es crítico para la autorización".
- 5Preguntá back. "¿Tiene sentido este trade-off para el caso de uso que estás pensando?" muestra que es una conversación, no un monólogo.
- 6Diferenciá el happy path del failure path. Primero describís el flujo cuando todo sale bien, después hablás de fallos. Nunca al revés.
Practicá esta pregunta antes de la entrevista
El sistema de pagos es un tema que requiere práctica en voz alta, no solo leer. Intentá explicar el flujo completo —desde que el usuario hace clic en "Pagar" hasta que el comercio recibe el dinero— sin mirar estas notas. Si te trabás en idempotencia, consistencia o reconciliación, ese es el nodo que más necesitás reforzar.
Un buen ejercicio: explicale el diagrama de arquitectura a alguien que no sea técnico. Si podás hacerlo en términos simples, lo entendés bien. Si solo podés explicarlo con jerga, hay partes que todavía son automáticas, no comprendidas.