Files
Webflux-Payments/CLAUDE.md
2026-07-02 16:45:48 +03:00

6.9 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 a providerReference.
  • 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.

Provider specifics:

  • M-Pesa: providerReference = CheckoutRequestID, secondaryReference = MerchantRequestID; STK query "still processing" (errorCode 500.001.1001) maps to ProviderProcessingException → 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-Id UUID (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 needs mtn.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/Flux throughout). Never block a reactive pipeline with .block() (startup schema init is the one deliberate exception).
  • All outbound provider calls live in client/*Client classes 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 in application.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 in client/ProviderHttpErrors (401 evicts the token so the retry refetches).
  • TokenCacheService is the tiered token cache for all providers: Redis (<provider>:access_token) → PROVIDER_TOKENS table → live OAuth fetch (persisted to both tiers). Redis being down never fails a request (800ms timeouts, falls through). <Provider>TokenService classes only supply the fetch call.
  • Boot 4 defaults to Jackson 3 (tools.jackson.databind.ObjectMapper) — inject that type, not com.fasterxml. The com.fasterxml.jackson.annotation.* annotations still work.
  • Resilience4j 2.4.0 with resilience4j-spring-boot4; annotations require aspectjweaver (Boot 4 removed spring-boot-starter-aop). Boot 4 also split WebClient auto-config into spring-boot-starter-webclient.
  • Package convention: models holds only database entities (@Table) plus DatabaseSchema; everything crossing an API boundary lives in dto.

Persistence (in-memory H2 via R2DBC):

  • The schema lives in code: models/DatabaseSchema.STATEMENTS, executed at startup by configurations/DatabaseSchemaInitializer (there is no schema.sql; spring.sql.init is 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) and provider_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 by PaymentLifecycleService.recordTransaction whenever 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.
  • @Table names must be UPPERCASE — H2 stores unquoted DDL identifiers uppercase and Spring Data quotes entity names verbatim.
  • PaymentReconciliationJob reconciles PENDING initiations of all providers older than payments.reconciliation.pending-age (default 5m) by dispatching to the right PaymentProviderService; interval payments.reconciliation.fixed-delay (default 60s).

Configuration (application.yml):

  • Config is read via Environment.getProperty by project convention (no @ConfigurationProperties).
  • mpesa.* (live sandbox keys), airtel.* and mtn.* (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 ProviderBusyException from 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.py pattern) — point airtel.base-url/mtn.base-url at a stub to test without credentials.