Enrique Valdivia Rios Portafolio - Senior Backend Engineer | Java · Spring Boot · Kubernetes | Fintech & Payments | Scalable Distributed Systems

Payment Gateway API: Building a Production-Grade Payment System

One of the biggest challenges in fintech backend development is implementing a payment system that is secure, scalable, and maintainable. In this project I built a payment gateway API from scratch using Spring Boot 3.2, applying patterns I use daily in production.

The full source code is available on my GitHub repository.


The Problem

E-commerce platforms need to process card payments reliably. This involves:


Architecture

The project follows a layered architecture with clear separation of concerns:

Controller (DTOs) → Service (logic) → Repository (persistence)

                   CardProcessor (external abstraction)

Tech Stack

LayerTechnology
FrameworkSpring Boot 3.2
DatabasePostgreSQL 15
CacheRedis 7
MigrationsFlyway
DocumentationOpenAPI 3.0 / Swagger UI
TestingJUnit 5, Mockito, Testcontainers
ContainersDocker / Docker Compose

Design Decisions

1. Card Processor Abstraction

Instead of coupling payment logic to a specific PSP, I created a CardProcessor interface that any implementation can satisfy:

public interface CardProcessor {
    ProcessorResult authorize(Payment payment);
    ProcessorResult capture(Payment payment);
    ProcessorResult refund(Payment payment);
    ProcessorResult reverse(Payment payment);
}

For development and testing, I included a SimulatedCardProcessor that follows standard test card conventions:

// Cards ending in "0002" → insufficient_funds
// Cards ending in "0003" → processor_error
// Any other → approved

This allows running the full application without depending on an external sandbox.

2. DTOs and Contract Separation

The controller never exposes the JPA entity directly. It uses immutable records:

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 with RFC 7807

All error responses use ProblemDetail (RFC 7807 standard), enabling clients to parse errors consistently:

@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. Idempotency

The Idempotency-Key header prevents duplicate charges. If a request arrives with a key that already exists, the original payment is returned without reprocessing:

if (payment.getIdempotencyKey() != null) {
    Optional<Payment> existing = paymentRepository
        .findByIdempotencyKey(payment.getIdempotencyKey());
    if (existing.isPresent()) {
        return existing.get(); // return without reprocessing
    }
}

Payment Lifecycle

The full payment flow follows this state diagram:

PENDING → AUTHORIZED → CAPTURED → REFUNDED

          REVERSED

           FAILED

Each transition validates the current state before proceeding:

if (payment.getStatus() != PaymentStatus.AUTHORIZED) {
    throw new PaymentStateException(
        payment.getStatus(), PaymentStatus.AUTHORIZED);
}

API Endpoints

MethodEndpointDescription
POST/api/v1/paymentsCreate and authorize a payment
GET/api/v1/payments/{id}Get payment status
POST/api/v1/payments/{id}/captureCapture an authorized payment
POST/api/v1/payments/{id}/refundRefund a captured payment
POST/api/v1/payments/{id}/reverseReverse an authorization

Usage Example

# Create a payment
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"}'

# Capture the payment
curl -X POST http://localhost:8080/api/v1/payments/{id}/capture \
  -H "X-Merchant-Id: merchant_001"

Testing

The project has 33 tests organized in four levels:

@Test
void shouldFailWhenDeclined() {
    when(cardProcessor.authorize(any()))
        .thenReturn(ProcessorResult.declined("insufficient_funds"));

    Payment result = paymentService.createPayment(payment);

    assertEquals(PaymentStatus.FAILED, result.getStatus());
}

Additionally, there is an integration test with Testcontainers that spins up a real PostgreSQL instance to validate the repository and Flyway migrations.


How to Run

git clone https://github.com/enriquevaldivia1988/payment-gateway-api.git
cd payment-gateway-api

# Start infrastructure
docker-compose up -d

# Or for local development
docker-compose up -d postgres redis
./mvnw spring-boot:run

# Run tests
./mvnw test

Swagger UI available at http://localhost:8080/swagger-ui.html.


Conclusion

This project demonstrates how to structure a production-ready payment API: with layered separation, external dependency abstraction, robust error handling, and a test suite covering both business logic and database integration.


Enrique Valdivia

View source on GitHub