Ledger is a Stripe-inspired payment processing engine built with Java 21 and Spring Boot 3.3. It demonstrates the engineering foundations behind a reliable payment API: deterministic charge simulation, database-backed idempotency, an append-only event ledger, and signed webhook delivery with retries.
This is a portfolio project for exploring transactional boundaries, immutable audit data, API contracts, and failure handling without connecting to a real card network or moving real money.
Client
│ X-Api-Key + optional Idempotency-Key
▼
API key filter → idempotency filter → REST controllers
│
▼
domain services
┌────────────┼────────────┐
▼ ▼ ▼
Charges Refunds Event ledger
│ │ │
└────────────┴────────────┘
▼
PostgreSQL 16 + Flyway
│
terminal payment events trigger async work
▼
webhook deliveries → signed RestClient POST
▲
retry scheduler / backoff
The REST layer validates input and maps requests to DTOs. Services own business rules and transactions. JPA repositories persist charges, refunds, immutable payment events, idempotency records, webhook endpoints, and delivery attempts. Flyway owns every schema change; Hibernate runs with ddl-auto=validate.
Requirements: Java 21, Docker, and a shell that can execute the Maven wrapper.
docker compose up -d
./mvnw spring-boot:runThe API listens on http://localhost:8080. OpenAPI documentation is available at Swagger UI, and the raw specification is at http://localhost:8080/v3/api-docs.
Useful project checks:
./mvnw test
./mvnw checkstyle:check
./mvnw test jacoco:report
open target/site/jacoco/index.htmlThe local PostgreSQL container uses database payments_db, user payments, and password payments on port 5432. Flyway applies the schema and seed migrations at application startup.
Protected endpoints require X-Api-Key: test_key. The key is intentionally a local-demo configuration value, not a production credential. Errors use a consistent envelope:
{
"error": {
"code": "invalid_request",
"message": "amountInKobo is required",
"param": "amountInKobo"
}
}The primary endpoints are:
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/charges |
Create a simulated charge |
GET |
/v1/charges/{id} |
Retrieve a charge |
GET |
/v1/charges/{id}/events |
Read the charge’s ledger events |
POST |
/v1/refunds |
Create a full or partial refund |
GET |
/v1/refunds/{id} |
Retrieve a refund |
POST |
/v1/webhook-endpoints |
Register a webhook destination |
DELETE |
/v1/webhook-endpoints/{id} |
Disable a webhook destination |
The card number is used only to choose a deterministic outcome. These are synthetic test values; no card data is sent to a network or treated as real payment information.
| Card-number suffix | Result | Failure code |
|---|---|---|
0000 |
SUCCEEDED |
— |
1111 |
FAILED |
card_declined |
2222 |
FAILED |
insufficient_funds |
| Anything else | SUCCEEDED |
— |
Amounts are stored as amountInKobo (the smallest currency unit) using Long, never floating-point numbers.
Create a successful charge:
curl -X POST http://localhost:8080/v1/charges \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: test_key' \
-d '{"amountInKobo":500000,"currency":"NGN","cardNumber":"4242424200000000","description":"Portfolio checkout"}'Create an idempotent charge. Repeating the same request with the same key returns the saved response and Idempotency-Replayed: true:
curl -X POST http://localhost:8080/v1/charges \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: test_key' \
-H 'Idempotency-Key: order-123' \
-d '{"amountInKobo":500000,"currency":"NGN","cardNumber":"4242424200000000"}'curl -i -X POST http://localhost:8080/v1/charges \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: test_key' \
-H 'Idempotency-Key: order-123' \
-d '{"amountInKobo":500000,"currency":"NGN","cardNumber":"4242424200000000"}'Use the charge ID returned by the first call to retrieve it or create a refund:
curl http://localhost:8080/v1/charges/<charge-id> \
-H 'X-Api-Key: test_key'
curl -X POST http://localhost:8080/v1/refunds \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: test_key' \
-d '{"chargeId":"<charge-id>","amountInKobo":250000,"reason":"Customer request"}'Register a webhook endpoint. Replace the URL with a reachable receiver when testing delivery:
curl -X POST http://localhost:8080/v1/webhook-endpoints \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: test_key' \
-d '{"url":"http://localhost:9000/webhooks","enabledEvents":["charge.succeeded","refund.succeeded"]}'Mutating POST requests may include an Idempotency-Key. The filter hashes the request body and stores an in-flight record before the controller runs. A concurrent request with the same key receives 409 Conflict; a completed request replays its stored status and response body without executing business logic. A key reused with a different request body is also rejected. Records expire after 24 hours and are cleaned up hourly.
The database record and row locking provide a durable coordination point across application threads. This keeps retries safe without requiring a separate cache or message broker at the current project scale.
Every charge and refund transition appends an immutable PaymentEvent. Terminal charge and refund events create deliveries for active endpoints whose event filter includes the event or *. Each webhook request includes:
Content-Type: application/jsonX-Payments-Event: <event-type>X-Payments-Signature: sha256=<HMAC-SHA256(body, endpoint-secret)>
Successful 2xx responses mark a delivery as DELIVERED. Failures are persisted and retried asynchronously by the scheduled worker using this maximum eight-attempt schedule:
| Attempt | Retry delay |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 8 hours |
| 8 | 24 hours |
The dispatcher uses a bounded webhookExecutor so webhook latency does not block request threads. Failed deliveries remain observable in PostgreSQL, including attempt count, retry time, response code, and response body.
- Append-only events: payment events are audit records, so the application never updates or deletes them. This makes state transitions inspectable and gives webhook creation a durable source of truth.
- Database-backed idempotency: PostgreSQL locking makes duplicate requests safe across multiple application threads and restarts. Redis could reduce database load at larger scale, but would add another operational dependency and still require durable response storage for reliable replay.
- Asynchronous webhooks: charge and refund requests do not wait for a merchant endpoint. Persisted deliveries, HMAC signatures, bounded concurrency, and exponential retries make external notification reliable while keeping the payment API responsive.
- Simulated processor: deterministic suffix rules make tests and demonstrations repeatable while keeping the project independent of a real card network.
- Flyway migrations: schema evolution is explicit and reviewable; Hibernate validates the mapped schema rather than creating it.
The test suite uses JUnit 5, Mockito, Spring Boot integration tests, Testcontainers PostgreSQL, and MockWebServer. Phase 8 adds Checkstyle and JaCoCo reporting; the service package is above 80% line coverage and the idempotency package is above 80% line coverage.
The current scope intentionally leaves some production features for follow-up work: pagination and filtering for ledger queries, a merchant-facing dashboard, broader multi-currency support, a capture workflow for authorised charges, and operational metrics beyond Actuator health.