Payment Gateway API: Construyendo una Pasarela de Pagos Profesional
Uno de los mayores retos en el desarrollo backend fintech es implementar un sistema de pagos que sea seguro, escalable y fácil de mantener. En este proyecto construí una API de pasarela de pagos desde cero usando Spring Boot 3.2, aplicando patrones que uso día a día en producción.
El código completo está en mi repositorio de GitHub.
El Problema
Las plataformas e-commerce necesitan procesar pagos con tarjeta de forma confiable. Esto implica:
- Multitenant: cada merchant debe ver solo sus transacciones
- Idempotencia: si el cliente reintenta una petición, no debe cobrarse dos veces
- Ciclo de vida completo: autorizar, capturar, reembolsar y reversar pagos
- Abstracción del procesador: poder cambiar de PSP (Stripe, Adyen, etc.) sin tocar la lógica de negocio
Arquitectura
El proyecto sigue una arquitectura en capas con separación clara de responsabilidades:
Controller (DTOs) → Service (lógica) → Repository (persistencia)
↓
CardProcessor (abstracción externa)
Stack Tecnológico
| Capa | Tecnología |
|---|---|
| Framework | Spring Boot 3.2 |
| Base de datos | PostgreSQL 15 |
| Cache | Redis 7 |
| Migraciones | Flyway |
| Documentación | OpenAPI 3.0 / Swagger UI |
| Testing | JUnit 5, Mockito, Testcontainers |
| Contenedores | Docker / Docker Compose |
Decisiones de Diseño
1. Abstracción del Procesador de Tarjetas
En lugar de acoplar la lógica de pagos a un PSP específico, creé una interfaz CardProcessor que cualquier implementación puede satisfacer:
public interface CardProcessor {
ProcessorResult authorize(Payment payment);
ProcessorResult capture(Payment payment);
ProcessorResult refund(Payment payment);
ProcessorResult reverse(Payment payment);
}
Para desarrollo y testing, incluí un SimulatedCardProcessor que sigue convenciones estándar de tarjetas de prueba:
// Tarjetas que terminan en "0002" → insufficient_funds
// Tarjetas que terminan en "0003" → processor_error
// Cualquier otra → aprobada
Esto permite correr la aplicación completa sin depender de un sandbox externo.
2. DTOs y Separación de Contratos
El controller nunca expone la entidad JPA directamente. Usa records inmutables:
public record PaymentRequest(
@NotNull @Min(1) Long amount,
@NotBlank @Size(min = 3, max = 3) String currency,
@Size(min = 4, max = 4) String cardLastFour
) {}
public record PaymentResponse(
UUID id, String merchantId, Long amount,
String currency, PaymentStatus status,
String processorRef, Instant createdAt
) {
public static PaymentResponse from(Payment payment) { ... }
}
3. Error Handling con RFC 7807
Todas las respuestas de error usan ProblemDetail (estándar RFC 7807), lo que hace que los clientes puedan parsear errores de forma consistente:
@ExceptionHandler(PaymentNotFoundException.class)
public ProblemDetail handleNotFound(PaymentNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Payment Not Found");
return problem;
}
4. Idempotencia
El header Idempotency-Key previene cobros duplicados. Si un request llega con una key que ya existe, se retorna el payment original sin procesarlo de nuevo:
if (payment.getIdempotencyKey() != null) {
Optional<Payment> existing = paymentRepository
.findByIdempotencyKey(payment.getIdempotencyKey());
if (existing.isPresent()) {
return existing.get(); // retorna sin re-procesar
}
}
Ciclo de Vida del Pago
El flujo completo de un pago sigue este diagrama de estados:
PENDING → AUTHORIZED → CAPTURED → REFUNDED
↓
REVERSED
↓
FAILED
Cada transición valida el estado actual antes de proceder:
if (payment.getStatus() != PaymentStatus.AUTHORIZED) {
throw new PaymentStateException(
payment.getStatus(), PaymentStatus.AUTHORIZED);
}
API Endpoints
| Método | Endpoint | Descripción |
|---|---|---|
POST | /api/v1/payments | Crear y autorizar un pago |
GET | /api/v1/payments/{id} | Consultar estado del pago |
POST | /api/v1/payments/{id}/capture | Capturar pago autorizado |
POST | /api/v1/payments/{id}/refund | Reembolsar pago capturado |
POST | /api/v1/payments/{id}/reverse | Reversar autorización |
Ejemplo de uso
# Crear un pago
curl -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "X-Merchant-Id: merchant_001" \
-H "Idempotency-Key: order-12345" \
-d '{"amount": 2500, "currency": "USD", "cardLastFour": "4242"}'
# Capturar el pago
curl -X POST http://localhost:8080/api/v1/payments/{id}/capture \
-H "X-Merchant-Id: merchant_001"
Testing
El proyecto tiene 33 tests organizados en cuatro niveles:
- Unit tests del servicio (13 tests): cubren autorización, captura, reembolso, reversión, idempotencia y casos de error usando Mockito
- Tests del controller (9 tests): validan endpoints, respuestas HTTP, y manejo de errores con MockMvc
- Tests del procesador (6 tests): verifican el comportamiento de las tarjetas de prueba
- Tests de integración (5 tests): validan el repository con PostgreSQL real usando Testcontainers
@Test
void shouldFailWhenDeclined() {
when(cardProcessor.authorize(any()))
.thenReturn(ProcessorResult.declined("insufficient_funds"));
Payment result = paymentService.createPayment(payment);
assertEquals(PaymentStatus.FAILED, result.getStatus());
}
Adicionalmente hay un test de integración con Testcontainers que levanta un PostgreSQL real para validar el repository y las migraciones Flyway.
Cómo Ejecutar
git clone https://github.com/enriquevaldivia1988/payment-gateway-api.git
cd payment-gateway-api
# Levantar infraestructura
docker-compose up -d
# O para desarrollo local
docker-compose up -d postgres redis
./mvnw spring-boot:run
# Ejecutar tests
./mvnw test
Swagger UI disponible en http://localhost:8080/swagger-ui.html.
Conclusión
Este proyecto demuestra cómo estructurar una API de pagos lista para producción: con separación de capas, abstracción de dependencias externas, manejo robusto de errores, y una suite de tests que cubre tanto lógica de negocio como integración con la base de datos.
Enrique Valdivia