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:
- Multi-tenancy: each merchant should only see their own transactions
- Idempotency: if a client retries a request, the charge must not be duplicated
- Full lifecycle: authorize, capture, refund, and reverse payments
- Processor abstraction: swap PSPs (Stripe, Adyen, etc.) without touching business logic
Architecture
The project follows a layered architecture with clear separation of concerns:
Controller (DTOs) → Service (logic) → Repository (persistence)
↓
CardProcessor (external abstraction)
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Spring Boot 3.2 |
| Database | PostgreSQL 15 |
| Cache | Redis 7 |
| Migrations | Flyway |
| Documentation | OpenAPI 3.0 / Swagger UI |
| Testing | JUnit 5, Mockito, Testcontainers |
| Containers | Docker / 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
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/payments | Create and authorize a payment |
GET | /api/v1/payments/{id} | Get payment status |
POST | /api/v1/payments/{id}/capture | Capture an authorized payment |
POST | /api/v1/payments/{id}/refund | Refund a captured payment |
POST | /api/v1/payments/{id}/reverse | Reverse 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:
- Service unit tests (13 tests): cover authorization, capture, refund, reversal, idempotency, and error scenarios using Mockito
- Controller tests (9 tests): validate endpoints, HTTP responses, and error handling with MockMvc
- Processor tests (6 tests): verify test card behavior
- Integration tests (5 tests): validate the repository with real PostgreSQL using Testcontainers
@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