Diseño de sistema de pagos: idempotencia, conciliación y reembolsos

1. Descripción general

El problema de los pagos difiere de las operaciones CRUD comunes en un punto: un error significa dinero real perdido. Los reintentos del cliente pueden resultar en cargos duplicados. El gateway de pagos (PSP) puede agotarse el tiempo de espera a pesar de que la transacción ya fue exitosa. Dos solicitudes de reembolso ejecutadas en paralelo pueden devolver más de lo que se cobró.

Este artículo recorre un diseño específico que incluye requisitos, modelo de datos, API y flujos de procesamiento. Luego aborda tres puntos difíciles: idempotencia, conciliación (reconciliation) y reembolsos (refunds). Después de leerlo, podrás construir la parte central usando Spring Boot 3 y PostgreSQL, con pruebas que demuestren que el sistema nunca devuelve más de lo que debería en caso de disputas.

2. Requisitos

2.1. Requisitos funcionales

  • El comerciante crea un pago para una Order. El sistema llama al PSP para descontar la tarjeta o billetera.
  • El comerciante reembolsa en su totalidad o parcialmente. El monto total reembolsado no debe exceder lo cobrado.
  • Diariamente, el sistema concilia los datos internos con el archivo de settlement del PSP.
  • El sistema emite eventos (PaymentCaptured, RefundSucceeded…) para otros servicios como Order y Accounting.

2.2. Escala y restricciones

  • Promedio 200 transacciones/segundo. En flash sales alcanza alrededor de 5.000 transacciones/segundo por algunos minutos.
  • Nunca cargar dos veces por la misma intención de pago. Esta es una restricción difícil.
  • Priorizar consistencia sobre disponibilidad: es mejor retornar un error al cliente que escribir un monto incorrecto.
  • El PSP es un sistema externo. Puede ser lento, agotarse el tiempo de espera, o enviar webhooks atrasados o duplicados.

3. Modelo de datos

El dinero siempre se guarda en la unidad más pequeña (VND es en dongs, USD en centavos) con tipo BIGINT. Nunca usar double.

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    merchant_id     TEXT        NOT NULL,
    order_id        TEXT        NOT NULL,
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    currency        CHAR(3)     NOT NULL,
    status          TEXT        NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
    refunded_amount BIGINT      NOT NULL DEFAULT 0,
    psp_reference   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (refunded_amount <= amount)
);

CREATE TABLE refunds (
    id          UUID PRIMARY KEY,
    payment_id  UUID   NOT NULL REFERENCES payments(id),
    amount      BIGINT NOT NULL CHECK (amount > 0),
    status      TEXT   NOT NULL, -- PENDING, SUCCEEDED, FAILED
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    merchant_id   TEXT NOT NULL,
    idem_key      TEXT NOT NULL,
    request_hash  TEXT NOT NULL,
    status        TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
    response_code INT,
    response_body TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idem_key)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT  NOT NULL,
    payload    JSONB NOT NULL,
    sent_at    TIMESTAMPTZ
);

Algunos puntos dignos de mención:

  • CHECK (refunded_amount <= amount) es la red de seguridad final en la capa de base de datos. Si hay un bug en el código, la base de datos lo bloqueará de todas formas.
  • La clave principal (merchant_id, idem_key) de la tabla idempotency_keys es el núcleo del mecanismo de prevención de duplicados.
  • La tabla outbox ayuda a emitir eventos a Kafka sin necesidad de una transacción distribuida (sección 6).

4. Diseño de API

POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body:    { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201  { "id": "...", "status": "CAPTURED" }

POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body:    { "amount": 100000 }
→ 202  { "refundId": "...", "status": "PENDING" }

GET  /v1/payments/{id}

Toda API que cambia estado es obligatoria que tenga una Idempotency-Key generada por el cliente. El cliente envía la misma clave al reintentar. Los reembolsos retornan 202 Accepted porque el PSP procesa reembolsos de forma asincrónica.

5. Idempotencia: evitar cargos duplicados

5.1. Idea

Cuando recibes una solicitud, escribes la clave en la base de datos antes de hacer nada. Hay tres posibilidades:

  1. La clave no existe: se escribe correctamente, continúa procesando.
  2. La clave está COMPLETED: retorna exactamente la respuesta guardada, no llama al PSP de nuevo.
  3. La clave está IN_PROGRESS: otra solicitud con la misma clave se está ejecutando. Retorna 409 Conflict para que el cliente reintente después.

Además, guardas un hash del cuerpo. Si el cliente reutiliza la clave con un cuerpo diferente (por ejemplo, amount diferente), eso es un error del cliente y retornas 422.

5.2. Implementación

Dependencias necesarias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {

    private final JdbcTemplate jdbc;

    public IdempotencyService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public record StoredResponse(int code, String body) {}

    public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
        int inserted = jdbc.update("""
            INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
            VALUES (?, ?, ?, 'IN_PROGRESS')
            ON CONFLICT (merchant_id, idem_key) DO NOTHING
            """, merchantId, key, requestHash);
        if (inserted == 1) {
            return Optional.empty(); // nueva solicitud, permitida procesarse
        }
        Map<String, Object> row = jdbc.queryForMap("""
            SELECT request_hash, status, response_code, response_body
            FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
            """, merchantId, key);
        if (!requestHash.equals(row.get("request_hash"))) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Clave reutilizada con cuerpo diferente");
        }
        if ("IN_PROGRESS".equals(row.get("status"))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "La solicitud está siendo procesada");
        }
        return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
    }

    public void complete(String merchantId, String key, int code, String body) {
        jdbc.update("""
            UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
            WHERE merchant_id = ? AND idem_key = ?
            """, code, body, merchantId, key);
    }
}

ON CONFLICT DO NOTHING convierte la operación “verificar luego escribir” en un único comando atómico. Si dos solicitudes con la misma clave llegan simultáneamente, solo una obtiene inserted == 1. La otra solicitud cae en la rama de lectura y recibe 409. Sin condiciones de carrera como “ambas ven que la clave no existe”.

Nota: No uses solo Redis SETNX como fuente de verdad para la idempotencia de pagos. El failover de Redis o la evicción de clave significa que pierdes el registro y el dinero se carga dos veces. Redis solo debe ser una capa de caché delante, la base de datos es el árbitro.

5.3. Idempotencia también en el lado del PSP

Tu solicitud está protegida, pero tu llamada al PSP también puede ser reintentada. La mayoría de PSPs soportan una clave de idempotencia propia. Pasa tu propio payments.id como esa clave:

PspResult result = pspClient.charge(new ChargeRequest(
        payment.id().toString(), // idempotency key enviado al PSP
        payment.amount(),
        payment.currency(),
        sourceToken));

Cuando la llamada al PSP agota el tiempo de espera, no sabes si el dinero fue cargado o no. No marques como FAILED. Establece el estado a UNKNOWN, luego ten un job que consulte de nuevo al PSP usando payments.id. Marcar como FAILED y dejar que el cliente reintente con una clave nueva es el camino clásico hacia cargar dinero dos veces.

6. Consistencia entre base de datos y Kafka: Transactional Outbox

Después de cobrar, necesitas actualizar payments y emitir el evento PaymentCaptured. Si la escritura en BD es exitosa pero fallar enviar a Kafka (o viceversa), el servicio de Order quedará con estado inconsistente. La solución es escribir el evento en la tabla outbox en la misma transacción:

@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
    jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
            pspReference, paymentId);
    jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
            """
            {"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}

Un relay (job periódico o Debezium CDC) lee las filas donde sent_at IS NULL, las envía a Kafka, luego actualiza sent_at. El relay puede enviar duplicados, así que el consumer debe ser idempotente, por ejemplo guardando el paymentId ya procesado. La condición status IN (...) en el UPDATE ayuda a que un webhook duplicado del PSP no sobrescriba un estado final diferente.

7. Reembolsos y disputas concurrentes

7.1. El problema

Pago de 100.000đ. Un agente de soporte al cliente y un job automático envían simultáneamente solicitudes de reembolso de 60.000đ. Si el código funciona leyendo refunded_amount, verificando y escribiendo, ambos ven 0 + 60.000 <= 100.000 y ambos se aprueban. El resultado es que devuelves 120.000đ.

7.2. Solución: actualización condicional

Combina verificación y actualización en un único UPDATE con condiciones:

@Service
public class RefundService {

    private final JdbcTemplate jdbc;

    public RefundService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public UUID requestRefund(UUID paymentId, long amount) {
        int reserved = jdbc.update("""
            UPDATE payments SET refunded_amount = refunded_amount + ?
            WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
            """, amount, paymentId, amount);
        if (reserved == 0) {
            throw new IllegalStateException("El reembolso excede el monto reembolsable o el pago no está capturado");
        }
        UUID refundId = UUID.randomUUID();
        jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
                refundId, paymentId, amount);
        jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
                """
                {"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
        return refundId;
    }
}

PostgreSQL bloquea la fila payments cuando ejecuta UPDATE. La segunda transacción debe esperar a que la primera haga commit. Luego reevalúa la condición WHERE sobre los datos nuevos, ve que 60.000 + 60.000 > 100.000 y obtiene reserved == 0. Este es el comportamiento predeterminado en el nivel READ COMMITTED, sin necesidad de SERIALIZABLE.

“Reservas” el monto primero, luego llamas al PSP de forma asincrónica a través del outbox. Si el PSP rechaza el reembolso, el consumer deshace la reserva:

UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds  SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';

Estas dos sentencias deben estar en la misma transacción. La condición status = 'PENDING' asegura que un webhook de fallo del PSP enviado duplicadamente no deshaga la reserva dos veces. Para ser más estricto, ejecutas la primera solo si la segunda actualiza exactamente una fila.

7.3. Test de concurrencia

Dependencias de prueba:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired RefundService refundService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
        UUID paymentId = UUID.randomUUID();
        jdbc.update("""
            INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
            VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
            """, paymentId);

        Callable<Boolean> refund60k = () -> {
            try {
                refundService.requestRefund(paymentId, 60_000);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        List<Boolean> results;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            results = executor.invokeAll(List.of(refund60k, refund60k))
                    .stream().map(Future::resultNow).toList();
        }

        assertThat(results).containsExactlyInAnyOrder(true, false);
        Long refunded = jdbc.queryForObject(
                "SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
        assertThat(refunded).isEqualTo(60_000L);
    }
}

El test asume que el schema de la sección 3 se carga a través de src/test/resources/schema.sql (junto con spring.sql.init.mode=always) o mediante Flyway. Dos virtual threads envían reembolso simultáneamente. El resultado esperado es siempre estable:

results          = [true, false]   (cualquier orden)
refunded_amount  = 60000

Si cambias el UPDATE por leer y comparar en Java, verás que el test falla ocasionalmente. Esa es exactamente la condición de carrera que este diseño elimina.

8. Conciliación (Reconciliation)

Sin importar cuánto cuides, los datos internos y del PSP pueden divergir. Los webhooks pueden perderse, puede haber transacciones UNKNOWN sin resolver, o el PSP puede reembolsar por su cuenta debido a un chargeback. La conciliación es la línea de defensa final.

Flujo diario:

  1. Descarga el archivo de settlement del PSP e ingiere en una tabla temporal psp_settlements(psp_reference, amount, type).
  2. Coincide con datos internos usando FULL OUTER JOIN.
  3. Escribe discrepancias en la tabla recon_breaks para manejo automático o manual.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
       p.amount,
       s.amount,
       CASE
           WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
           WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
           ELSE 'AMOUNT_MISMATCH'
       END
FROM (SELECT * FROM payments
      WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
       ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
   OR s.psp_reference IS NULL
   OR p.amount <> s.amount;

Significado de cada tipo de discrepancia:

  • MISSING_INTERNAL: el PSP cobró pero no tienes un registro CAPTURED internamente. Usualmente es una transacción UNKNOWN o un webhook perdido. Maneja actualizando estado, u reembolsando si la orden ya fue cancelada.
  • MISSING_AT_PSP: tu lado registra que cobraste pero el PSP no tiene el registro. Este es un caso grave porque posiblemente ya entregaste mercancía sin recibir el pago.
  • AMOUNT_MISMATCH: típicamente por comisiones, tipos de cambio, o bugs de redondeo.

Nota: Presta atención a las ventanas de tiempo. Una transacción a las 23:59 puede aparecer en el archivo de settlement del día siguiente. Concilia con retraso T+1 o T+2, e escalona una discrepancia solo después de que exista en dos ejecuciones consecutivas.

9. Manejo de carga imprevista

Durante un flash sale, la carga aumenta 25 veces. Algunas técnicas ayudan al sistema a soportarlo:

  • Rate limit por comerciante usando Redis (INCR + EXPIRE por ventana de un segundo). Esto protege el sistema de un comerciante “ruidoso”. Aquí Redis solo se usa para limitación, no decide la corrección de una transacción.
  • Separar lo sincrónico de lo asincrónico. La ruta sincrónica solo incluye escribir idempotency key, crear pago y llamar al PSP para autorizar. Reembolsos, envío de email y actualización de accounting van a través del outbox → Kafka. Particiona Kafka por paymentId para mantener el orden de eventos por pago.
  • Circuit breaker y timeouts cortos cuando llamas al PSP (por ejemplo Resilience4j). Cuando el PSP es lento, falla rápido y retorna 503 con Retry-After, en lugar de agotar el thread pool. El cliente reintenta con la misma Idempotency-Key y es seguro gracias a la sección 5.
  • Limpiar la tabla idempotency_keys periódicamente (mantener 24–72 horas) y particionar por día, evitando que la tabla crezca enormemente y ralentice los INSERT.

10. Errores comunes

  • Usar double/float para dinero. Usa long en la unidad más pequeña, o BigDecimal cuando necesites tipos de cambio.
  • Tratar timeout como fallo. Timeout significa desconocido, así que debes establecer UNKNOWN y consultar de nuevo al PSP.
  • Generar la idempotency key en el servidor. La clave debe ser generada por el cliente y mantenerse igual en los reintentos.
  • Verificar el saldo de reembolso en Java y luego hacer UPDATE. Deja que la base de datos lo haga en un único comando.
  • Enviar a Kafka dentro de @Transactional esperando que haga rollback junto con la base de datos. No hará rollback. Usa outbox.
  • Ignorar la conciliación porque “el código es correcto”. Los sistemas externos no están bajo tu control.

11. Conclusión

Este artículo ha diseñado el núcleo de un sistema de pagos basado en tres principios. Primero, la idempotencia se garantiza mediante restricciones unique en la base de datos, tanto en tu API como cuando llamas al PSP. Segundo, las disputas en reembolsos se resuelven mediante actualización condicional más restricción CHECK, con pruebas concurrentes que lo demuestran. Tercero, consistencia con el mundo externo basada en outbox transaccional y conciliación diaria.

Lo común en los tres es empujar los invariantes importantes hacia la capa de base de datos, donde se aplican atómicamente. Al mismo tiempo, siempre asume que la red, el PSP y tu propio código pueden fallar, y prepara mecanismos para detectar y corregir errores.

system-designpaymentidempotencyspring-bootpostgresql