Files
Webflux-Payments/CLAUDE.md
spiro-alvin-nyasimi c6a6755b08 Updates
2026-07-27 10:27:58 +03:00

78 lines
8.0 KiB
Markdown

# 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):**
`<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.
- `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<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.
- `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 `<Provider>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.