# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Build & Run Commands ```bash # 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):** `Controller` → `Service` (implements `PaymentProviderService`; parses provider payloads) → `PaymentLifecycleService` (ALL persistence: initiation/response/callback/transaction, dedup, status transitions) and `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. - `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 to `ProviderProcessingException` → stays PENDING. Sandbox creds in yml are live. - Airtel: reference is a generated `ATL` 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 (`:access_token`) → `PROVIDER_TOKENS` table → live OAuth fetch (persisted to both tiers). Redis being down never fails a request (800ms timeouts, falls through). `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. - `PROVIDER_LIMITS` — configurable payment ceilings, `UNIQUE (provider, period, scope)`. Periods come from the `LimitPeriod` enum (PER_TRANSACTION, DAILY, MONTHLY — adding a constant is all a new period needs); `scope` from the `LimitScope` enum (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 by `DatabaseSchema.SEED_STATEMENTS` only when the triple is absent, so runtime edits survive on a persistent DB. `PaymentLimitService.enforce` runs in every `Service.initiatePayment` **before** the initiation is persisted (breaches leave no DB row) and raises `PaymentLimitExceededException` → 422 `LIMIT_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 unrecognised `scope` on a row falls back to PER_PAYER (the tighter interpretation). - `@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.