Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ledger — Payment Processing Engine

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.

Architecture

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.

Run locally

Requirements: Java 21, Docker, and a shell that can execute the Maven wrapper.

docker compose up -d
./mvnw spring-boot:run

The 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.html

The 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.

Authentication and API shape

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

Card simulation

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.

Example API calls

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"]}'

Idempotency

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.

Event ledger and webhooks

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/json
  • X-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.

Engineering decisions and tradeoffs

  • 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.

Quality status and follow-ups

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.

About

Stripe-inspired payment processing engine with idempotency, event ledger, and webhook delivery built with Java + Spring Boot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages