Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

remittance-engine

A quote-to-settlement remittance system: a sender gets a locked FX quote, executes an order against it, funds move through a (mocked) external payout network, and the system settles or compensates on failure. Built as a Spring Boot service to work through the pieces a real remittance backend needs: a double-entry ledger, an async payout saga, retries/circuit-breaking/dead-lettering, per-user and per-corridor rate limiting, and basic tracing across the saga.

Flow

POST /api/quotes          -> locked FX quote, cached in Redis with a TTL
POST /api/orders           -> claims the quote, creates an order (ORDER_CREATED)
                               background poller then drives it through:
                               ORDER_CREATED -> FUNDS_RESERVED -> PAYOUT_IN_PROGRESS -> SETTLED
                                                                -> FAILED -> COMPENSATED -> DEAD_LETTERED
GET  /api/orders/{id}      -> poll for status

Funds are reserved (debit sender, credit a PENDING_SETTLEMENT account) in one DB transaction before the payout call, and reversed in one DB transaction if the payout ultimately fails, so money is never left in limbo. See LedgerService for the double-entry posting logic and PayoutSagaScheduler/SagaBatchRunner for the saga orchestration.

Stack

Java 17, Spring Boot 3.3, Maven. Postgres (orders, ledger, dead letters) via Spring Data JPA + Flyway. Redis (quote cache, quote claim, rate limiting) via Spring Data Redis. Resilience4j (retry with backoff + jitter, circuit breaker) around the mock payout provider. Micrometer + OpenTelemetry for tracing.

Architecture is package-by-feature (quote, order, ledger, payout, saga, deadletter, ratelimit, observability) with a small in-process CQRS mediator (common.CommandBus/QueryBus) so every saga step is a Command dispatched through the bus - which is also what gives every step a tracing span and MDC context for free, via TracingCommandBus.

Run it

docker compose up -d              # Postgres + Redis
./mvnw spring-boot:run            # migrations run automatically on startup

App listens on :8080. Swagger UI at /swagger-ui.html.

QUOTE=$(curl -s -X POST localhost:8080/api/quotes -H 'Content-Type: application/json' \
  -d '{"userId":"u1","sourceCurrency":"USD","destCurrency":"PHP","sourceAmount":100}')
QUOTE_ID=$(echo "$QUOTE" | jq -r .quoteId)

ORDER=$(curl -s -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
  -d "{\"quoteId\":\"$QUOTE_ID\",\"userId\":\"u1\"}")
ORDER_ID=$(echo "$ORDER" | jq -r .orderId)

watch -n2 "curl -s localhost:8080/api/orders/$ORDER_ID | jq ."

Two demo wallets (u1, u2) are seeded with $10,000 USD. The mock payout provider fails about 15% of the time; to see the failure path deterministically instead of waiting on the roll:

curl -X POST localhost:8080/api/dev/orders/$ORDER_ID/force-failure   # call right after creating the order
curl -s localhost:8080/api/ops/dead-letters | jq .                    # once it reaches DEAD_LETTERED

Test

./mvnw test      # unit tests
./mvnw verify     # + integration tests (Testcontainers: Postgres, Redis - needs Docker running)

Tracing

Spans print as structured log lines by default (LoggingSpanExporter, zero setup). Point OTEL_EXPORTER_OTLP_ENDPOINT at a real collector (Jaeger/Tempo/otel-collector) and flip management.otlp.tracing.export.enabled=true to export there instead.

Known simplifications

  • The ledger doesn't model cross-currency conversion: PENDING_SETTLEMENT and EXTERNAL_CLEARING are denominated in the sender's currency, representing funds handed off to the payout network. The destination-currency amount is carried on the order for display/audit, not double-entried. A real system would need an FX/treasury layer for that.
  • PENDING_SETTLEMENT/EXTERNAL_CLEARING are hot rows under pessimistic locking - fine at this scale, a real high-throughput system would shard or batch them.
  • The saga poller processes batches sequentially, single instance. SELECT ... FOR UPDATE SKIP LOCKED is there so it's already safe to run multiple instances; parallelizing within a batch is a small addition, not required for this to be correct.

About

Quote-to-settlement remittance engine in Spring Boot: double-entry ledger, async payout saga with retries/circuit-breaking/dead-lettering, corridor rate limiting, and distributed tracing.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages