8.0 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 the default port 8080. Swagger UI is available at http://localhost:8080/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 R2DBC for async DB access, Redis (Lettuce) for token caching, and Resilience4j for fault tolerance.
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 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:
- All I/O is non-blocking (
Mono/Fluxthroughout). Never block a reactive pipeline with.block()(startup schema init is the one deliberate exception). - 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 (@Table) plusDatabaseSchema; everything crossing an API boundary lives indto.
Persistence (in-memory H2 via R2DBC):
- The schema lives in code:
models/DatabaseSchema.STATEMENTS, executed at startup byconfigurations/DatabaseSchemaInitializer(there is no schema.sql;spring.sql.initis not used). PAYMENT_INITIATIONS— one row per payment attempt, any provider (status: PENDING → SUCCESS/FAILED).PAYMENT_RESPONSES— the provider's answer,initiation_id UNIQUE(1:1) andprovider_reference UNIQUE(lookup key for callbacks/status).PAYMENT_CALLBACKS— the provider result callback,initiation_id UNIQUE, duplicates ignored, raw payload stored as JSON.TRANSACTIONS— consolidated record upserted byPaymentLifecycleService.recordTransactionwhenever an initiation reaches a terminal state, from whichever path resolved it (resolvedBy: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION).initiation_id UNIQUE.PROVIDER_TOKENS— OAuth tokens per provider 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 byDatabaseSchema.SEED_STATEMENTSonly when the triple is absent, so runtime edits survive on a persistent DB.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).@Tablenames must be UPPERCASE — H2 stores unquoted DDL identifiers uppercase and Spring Data quotes entity names verbatim.PaymentReconciliationJobreconciles PENDING initiations of all providers older thanpayments.reconciliation.pending-age(default 5m) by dispatching to the rightPaymentProviderService; intervalpayments.reconciliation.fixed-delay(default 60s).
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.