- StatusCatalog gains an Unresolved state and a PRECEDENCE order (Pending -> Unresolved -> Failed -> Success -> Paid); canTransition only allows moves up it, so a callback and a status query racing each other cannot make the status flap. A refused transition still stores the row. - PaymentInitiation carries a @Version optimistic lock; callback and query paths go through blockingWithRetry, which replays the losing unit of work once instead of dropping it. - Reconciliation runs two passes per tick, stale first: anything Pending past payments.reconciliation.stale-age (3h) is marked Unresolved with resolvedBy = EXPIRY, then anything past pending-age (now 3m) is re-queried. - Explicit lowercase @Table names on the entities that were relying on derived naming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & Run Commands
# Build
./gradlew build
# Run the application
./gradlew bootRun
# Run tests
./gradlew test
# Run a single test class
./gradlew test --tests "com.test.payment.PaymentApplicationTests"
# Clean build
./gradlew clean build
Requires JDK 21 to run Gradle (Gradle 8.14 cannot run on JDK 25; set JAVA_HOME accordingly).
The app starts on port 8091 (server.port). Swagger UI is at http://localhost:8091/swagger-ui.html.
Redis is optional at runtime (docker run -d --name mpesa-redis -p 6379:6379 redis:7-alpine) — the app degrades gracefully without it.
Architecture Overview
Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service (M-Pesa STK Push, Airtel Money, MTN MoMo), using JPA/Hibernate over PostgreSQL for persistence, Redis (Lettuce) for token caching, and Resilience4j for fault tolerance.
Blocking persistence under a reactive web layer. HTTP, WebClient and Resilience4j stay reactive; JPA blocks. The bridge is a facade/store split — PaymentLifecycleService and PaymentLimitService keep their Mono/Flux API and hand each unit of work to PaymentLifecycleStore / PaymentLimitStore via Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()). The stores are separate beans on purpose: @Transactional goes through a Spring AOP proxy that self-invocation would bypass (same trap as the Resilience4j annotations). Never call a repository from the event loop, and never hold a transaction across a provider HTTP call — checkStatus deliberately enters the store, exits, queries the provider, then re-enters.
Layering (same shape for every provider):
<Provider>Controller → <Provider>Service (implements PaymentProviderService; parses provider payloads) → PaymentLifecycleService (ALL persistence: initiation/response/callback/transaction, dedup, status transitions) and <Provider>Client (resilience-wrapped HTTP) → provider API.
Endpoints (identical pattern per provider under /api/mpesa, /api/airtel, /api/mtn):
POST /pay— validated request; persists an initiation, calls the provider, persists the linked response, returns the outcome with aproviderReference.POST /callback— provider result callback (MTN also accepts PUT); stored 1:1 with the initiation, deduplicated, updates status. Always acks.GET /status/{providerReference}— DB state; if still PENDING, performs a live provider status query and updates the DB. Degrades to last-known state when the provider rate-limits.GET /api/payments/transactions?provider=— consolidated transactions across providers.GET /api/payments/limits?provider=/PUT /api/payments/limits— read and upsert the configurable payment ceilings (body:provider,period,scope,maxAmount,currency,active).
Provider identity is market-qualified. Operator (MPESA/AIRTEL/MTN) is the integration — one client, one token, one set of Resilience4j instances. PaymentProviderType is Operator × Country: MPESA_KE, AIRTEL_KE, AIRTEL_UG, MTN_UG, … That enum name is what lands in every provider column and every provider-scoped lookup (limits, reconciliation dispatch, callback matching). Which market each operator runs in comes from <operator>.country in yml, resolved once by ProviderMarkets; an unsupported pairing fails at startup rather than mid-payment. provider columns are typed @Enumerated(EnumType.STRING), never raw Strings — likewise ProviderToken.provider, which is an Operator because credentials are per operator, not per market.
Provider specifics:
- M-Pesa:
providerReference= CheckoutRequestID,secondaryReference= MerchantRequestID; STK query "still processing" (errorCode 500.001.1001) maps toProviderProcessingException→ stays PENDING. Sandbox creds in yml are live. - Airtel: reference is a generated
ATL<uuid>transaction id; status codes TIP (pending) / TS (success) / TF (failed); receipt =airtel_money_id. Credentials are placeholders (airtel.*). - MTN MoMo: reference is the generated
X-Reference-IdUUID (externalId is set to the same value for callback correlation); request-to-pay returns 202 with no body; status SUCCESSFUL/FAILED/PENDING; receipt =financialTransactionId. Sandbox needsmtn.subscription-key,mtn.api-user,mtn.api-key; sandbox currency is EUR. Placeholders in yml.
Key design points:
- The web layer and all provider I/O are non-blocking (
Mono/Flux). Never block the event loop and never call.block(); database work reaches JPA only through the store beans onSchedulers.boundedElastic(). - All outbound provider calls live in
client/*Clientclasses so the Resilience4j annotations (@CircuitBreaker,@RateLimiter,@Retry) go through Spring AOP — they are silently skipped on self-invocation. Each provider has its own instances (mpesa*,airtel*,mtn*) configured only inapplication.yml(YAML anchors share the retry/circuit-breaker settings). - Shared exception model in
exceptions/:ProviderBusyException(429/busy → reactive backoff retry, 503),ProviderTransientException(Resilience4j retry, 502),ProviderPermanentException(never retried, CB-ignored, 400),ProviderProcessingException(still processing → stays PENDING). HTTP-to-exception mapping is shared inclient/ProviderHttpErrors(401 evicts the token so the retry refetches). TokenCacheServiceis the tiered token cache for all providers: Redis (<provider>:access_token) →PROVIDER_TOKENStable → live OAuth fetch (persisted to both tiers). Redis being down never fails a request (800ms timeouts, falls through).<Provider>TokenServiceclasses only supply the fetch call.- Boot 4 defaults to Jackson 3 (
tools.jackson.databind.ObjectMapper) — inject that type, notcom.fasterxml. Thecom.fasterxml.jackson.annotation.*annotations still work. - Resilience4j 2.4.0 with
resilience4j-spring-boot4; annotations requireaspectjweaver(Boot 4 removedspring-boot-starter-aop). Boot 4 also splitWebClientauto-config intospring-boot-starter-webclient. - Package convention:
modelsholds only database entities (@Entity) and the enums they persist (Operator,Country,PaymentProviderType,LimitPeriod,LimitScope);models/auditholds the provider-call audit entities; everything crossing an API boundary lives indto.
Persistence (PostgreSQL via JPA/Hibernate):
- Connection settings live under
spring.datasource.*(jdbc:postgresql://localhost:5432/payments, usermyapp), driverorg.postgresql:postgresql.spring.jpa.open-in-viewis off — WebFlux has no OSIV filter, so lazy access outside a transaction should fail loudly. - The database must exist and be reachable before startup, so
./gradlew build(which runs thecontextLoadstest) needs a live Postgres, or use-x test. On PostgreSQL 15+ the app's role also needsGRANT USAGE, CREATE ON SCHEMA public, or startup dies with SQLSTATE 42501. - Hibernate owns the DDL (
spring.jpa.hibernate.ddl-auto: update). There is no schema.sql and no hand-rolled initializer;configurations/DataSeederseeds only the reference data Hibernate cannot derive — theSTATUSESrows and the default provider ceilings — each guarded by an existence check so runtime edits survive a restart. STATUSES— the payment lifecycle states as rows, not an enum: Paid, Success, Pending, Failed.PaymentInitiationandTransactionreference it@ManyToOne(status_id). There is deliberately no enum mirroring these values:StatusCatalogis the single place that names the four the code branches on, and every caller asks it for a row (statuses.pending(),.paid(),.success(),.failed()) so the value served is whatever the table currently holds.StatusCatalog.defaults()is also whatDataSeederseeds from, so the names live in exactly one file. Rows are memoised, so a transition costs no query — callinvalidate()after inserting. Paid vs Success: a successful callback carrying a receipt number resolves to Paid, one without to Success.PAYMENT_INITIATIONS— one row per payment attempt, any provider.<operator>_PAYMENT_RESPONSES— the provider's answer, one table per operator (mpesa_payment_responses,airtel_…,mtn_…),initiation_id UNIQUE(1:1) andprovider_reference UNIQUE(lookup key for callbacks/status).<operator>_PAYMENT_CALLBACKS— the result callback that resolved the payment, one table per operator,initiation_id UNIQUE, duplicates ignored, raw payload stored as JSON. (The duplicate-inclusive record is the separate<operator>_callback_responsesaudit table.)- These six entities are standalone — no shared supertype, matching the audit tables.
PaymentLifecycleStoretherefore dispatches onOperatorand hands the rest of the lifecycle a provider-neutralPaymentLifecycleService.StoredResponseview record (not an entity, no table). Keep the provider equality check when resolving a reference: one operator's table holds every one of its markets, so a MPESA_TZ reference must not resolve against a MPESA_KE payment. TRANSACTIONS— the spine that ties a payment together. Created bysaveInitiationat request time, not at resolution, so a payment is never missing from this table;recordTransactionthen updates it as the payment progresses (resolvedBy: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION — left null while still open). References itsPaymentInitiation@ManyToOne(initiation_id, UNIQUE — one transaction per attempt), itsStatus, and the provider's response and callback.- A transaction points at the request and the callback, not the response: the response already hangs off the request (
MpesaResponse.request), so the request is the single anchor for everything the operator sent back. Because those tables are per operator with no shared supertype, each link is three nullable FKs —mpesa_request_id/airtel_request_id/mtn_request_id, and the same for callbacks — of which exactly one per trio is set.attachRequest/attachCallbackin the store pick the right one;hasRequest()/hasCallback()hide the null-checking. attachRequestlinks the earliest audit request for the initiation (later rows are status queries) and is best effort: audit rows are written asynchronously, so if one has not landed yet the link is picked up on the next update. Naming convention — PascalCase entity fields (CreatedByPartnerstyle). Every@Entityfield is PascalCase. Lombok still generates ordinarygetX()/setX()and builders take the PascalCase name (.Provider(...)). Three consequences, all of which have already bitten:- Spring Data derived queries break silently. The persistent property is
Initiation, notinitiation, sofindByInitiationIdfails at runtime withIllegalStateException: Binding property is null. Every repository therefore uses explicit@QueryJPQL with PascalCase paths (WHERE t.Initiation.Id = :id). Never add a derived query method.findFirstBy…has no JPQL equivalent either — those becameORDER BY …queries returning aListwhose head the caller takes. - Associations are
@ManyToOne(targetEntity = X.class, fetch = FetchType.LAZY)with@JoinColumn(name = "<FieldName>")— the column is named after the field, sotransactionshasinitiation,status,mpesa_request,mpesa_callbackrather than*_id. NoforeignKey = @ForeignKey(name = ...); constraint names are left to Hibernate. Because everything is LAZY andopen-in-viewis off, any query whose result is serialised by the web layer mustLEFT JOIN FETCHits associations — seeTransactionRepository.GRAPH, which the listing queries share. - Entity-returning endpoints now emit PascalCase JSON keys (
{"Id":1,"Provider":"MPESA_KE"}) —/api/payments/transactionsand/api/payments/limits. The DTO-returning endpoints (/pay,/status,/callback) are unaffected and stay camelCase, because DTOs are not part of this convention. <operator>_requests.initiation_idis a String, not the numeric id, so it can carry a NanoID; it is a loose reference with no FK.PROVIDER_TOKENS— OAuth tokens per operator (not per market) with expiry.PROVIDER_LIMITS— configurable payment ceilings,UNIQUE (provider, period, scope). Periods come from theLimitPeriodenum (PER_TRANSACTION, DAILY, MONTHLY — adding a constant is all a new period needs);scopefrom theLimitScopeenum (PER_PAYER buckets by paying MSISDN, MERCHANT sums every payer on the provider; defaults to PER_PAYER and is ignored by PER_TRANSACTION). Defaults are seeded byDataSeederonly when the triple is absent (and only for the markets<operator>.countryactually configures), so runtime edits survive a restart.PaymentLimitService.enforceruns in every<Provider>Service.initiatePaymentbefore the initiation is persisted (breaches leave no DB row) and raisesPaymentLimitExceededException→ 422LIMIT_EXCEEDED. Cumulative periods sum non-Failed initiations inside the window (per MSISDN for PER_PAYER, provider-wide for MERCHANT), so PENDING pushes count against the cap. An unrecognisedscopeon a row falls back to PER_PAYER (the tighter interpretation).- Provider-call audit trail (
models/audit,repository/audit,ProviderCallAudit): every outbound call and inbound callback is recorded per operator in its own independent tables —mpesa_requests/mpesa_responses/mpesa_callback_responses, and theairtel_*andmtn_*equivalents. Responses and callbacks reference their request@ManyToOne. The nine entities are fully standalone — no shared supertype, no@MappedSuperclass, no discriminator, no join.ProviderCallAudittherefore dispatches onOperatorwith an explicit branch per provider rather than polymorphically, which is verbose on purpose: a new operator will not compile until all three of its tables are wired up. Writes run on theauditExecutorpool (bounded queue, drop-on-overflow, daemon threads) so a payment is never delayed or failed by its audit trail; ordering within a call is kept by chaining onto the request'sCompletableFuturerather than blocking on it. Credentials matchingProviderCallAudit.SECRETS(Password, passkey, api-key, secret, authorization, access_token) are masked before anything is stored. @Tablenames must be lowercase — Postgres folds unquoted DDL identifiers to lowercase and Spring Data quotes an explicit entity name verbatim. Column names carry no@Columnannotation, so they are derived and adapt to the dialect's casing on their own; keep it that way.PaymentReconciliationJobruns two passes per tick (intervalpayments.reconciliation.fixed-delay, default 60s), stale first so hopeless payments stop being re-queried:- anything still Pending past
payments.reconciliation.stale-age(default 3h) →UnresolvedviamarkStaleUnresolved,resolvedBy = EXPIRY; - anything still Pending past
payments.reconciliation.pending-age(default 3m) → live provider status query, dispatched to the rightPaymentProviderService.
- anything still Pending past
- Status transitions are guarded, not blind.
StatusCatalog.PRECEDENCEorders the states least- to most-informed (Pending → Unresolved → Failed → Success → Paid) andcanTransitiononly allows moves up it. So a callback and a status query racing each other cannot make the status flap: re-asserting the current state is a silent no-op (DEBUG), a worse verdict is refused with a WARN, and a receipt-bearing callback can still rescue a payment the query gave up on (Unresolved → Paid). A refused transition never discards the row — the callback is still stored, only the status is held back. PaymentInitiationcarries a@Versionoptimistic lock. When a callback and a query genuinely commit at the same instant the loser's transaction rolls back;PaymentLifecycleService.blockingWithRetryreplays it once, so the callback row still lands and the guard then declines the redundant status change. Use it for any new path that resolves a payment.
Configuration (application.yml):
- Config is read via
Environment.getPropertyby project convention (no@ConfigurationProperties). mpesa.*(live sandbox keys),airtel.*andmtn.*(placeholders — fill in real credentials),payments.*(token buffer, reconciliation).- Callback URLs (
mpesa.callback-url,mtn.callback-url) must be publicly reachable for real callbacks. spring.data.redis.*— short 1s timeouts so a dead Redis degrades fast.
Sandbox gotchas (observed live):
- Safaricom's STK query endpoint is aggressively rate-limited (~few calls/minute) — expect
ProviderBusyExceptionfrom status checks/reconciliation during testing; the status endpoint then serves last-known DB state. - M-Pesa test phone 254708374149 typically ends as ResultCode 1037 ("DS timeout user cannot be reached") since no real handset confirms the push.
- Airtel/MTN flows were verified against a local mock of their APIs (
scratchpad/provider_mock.pypattern) — pointairtel.base-url/mtn.base-urlat a stub to test without credentials.