diff --git a/CLAUDE.md b/CLAUDE.md index 3a0f0a7..d5a11b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,12 +22,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ``` 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`. +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 R2DBC for async DB access, Redis (Lettuce) for token caching, and Resilience4j for fault tolerance. +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):** `Controller` → `Service` (implements `PaymentProviderService`; parses provider payloads) → `PaymentLifecycleService` (ALL persistence: initiation/response/callback/transaction, dedup, status transitions) and `Client` (resilience-wrapped HTTP) → provider API. @@ -39,6 +41,8 @@ Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service ( - `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 `.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 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.*`). @@ -46,23 +50,35 @@ Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service ( **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). +- 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 on `Schedulers.boundedElastic()`. - 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`. +- Package convention: `models` holds only database entities (`@Entity`) and the enums they persist (`Operator`, `Country`, `PaymentProviderType`, `LimitPeriod`, `LimitScope`); `models/audit` holds the provider-call audit entities; 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. +**Persistence (PostgreSQL via JPA/Hibernate):** +- Connection settings live under `spring.datasource.*` (`jdbc:postgresql://localhost:5432/payments`, user `myapp`), driver `org.postgresql:postgresql`. `spring.jpa.open-in-view` is 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 the `contextLoads` test) needs a live Postgres, or use `-x test`. On PostgreSQL 15+ the app's role also needs `GRANT 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/DataSeeder` seeds only the reference data Hibernate cannot derive — the `STATUSES` rows 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. `PaymentInitiation` and `Transaction` reference it `@ManyToOne` (`status_id`). There is deliberately **no enum mirroring these values**: `StatusCatalog` is 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 what `DataSeeder` seeds from, so the names live in exactly one file. Rows are memoised, so a transition costs no query — call `invalidate()` 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. +- `_PAYMENT_RESPONSES` — the provider's answer, one table per operator (`mpesa_payment_responses`, `airtel_…`, `mtn_…`), `initiation_id UNIQUE` (1:1) and `provider_reference UNIQUE` (lookup key for callbacks/status). +- `_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 `_callback_responses` audit table.) +- These six entities are standalone — no shared supertype, matching the audit tables. `PaymentLifecycleStore` therefore dispatches on `Operator` and hands the rest of the lifecycle a provider-neutral `PaymentLifecycleService.StoredResponse` view 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 by `saveInitiation` **at request time**, not at resolution, so a payment is never missing from this table; `recordTransaction` then updates it as the payment progresses (`resolvedBy`: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION — left null while still open). References its `PaymentInitiation` `@ManyToOne` (`initiation_id`, UNIQUE — one transaction per attempt), its `Status`, 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`/`attachCallback` in the store pick the right one; `hasRequest()` / `hasCallback()` hide the null-checking. +- `attachRequest` links 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 (`CreatedByPartner` style).** Every `@Entity` field is PascalCase. Lombok still generates ordinary `getX()`/`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`, not `initiation`, so `findByInitiationId` fails at runtime with `IllegalStateException: Binding property is null`. **Every repository therefore uses explicit `@Query` JPQL with PascalCase paths** (`WHERE t.Initiation.Id = :id`). Never add a derived query method. `findFirstBy…` has no JPQL equivalent either — those became `ORDER BY … ` queries returning a `List` whose head the caller takes. +- **Associations are `@ManyToOne(targetEntity = X.class, fetch = FetchType.LAZY)` with `@JoinColumn(name = "")`** — the column is named after the field, so `transactions` has `initiation`, `status`, `mpesa_request`, `mpesa_callback` rather than `*_id`. No `foreignKey = @ForeignKey(name = ...)`; constraint names are left to Hibernate. Because everything is LAZY and `open-in-view` is off, **any query whose result is serialised by the web layer must `LEFT JOIN FETCH` its associations** — see `TransactionRepository.GRAPH`, which the listing queries share. +- **Entity-returning endpoints now emit PascalCase JSON keys** (`{"Id":1,"Provider":"MPESA_KE"}`) — `/api/payments/transactions` and `/api/payments/limits`. The DTO-returning endpoints (`/pay`, `/status`, `/callback`) are unaffected and stay camelCase, because DTOs are not part of this convention. +- `_requests.initiation_id` is 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 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 `DataSeeder` only when the triple is absent (and only for the markets `.country` actually configures), so runtime edits survive a restart. `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). +- **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 the `airtel_*` and `mtn_*` equivalents. Responses and callbacks reference their request `@ManyToOne`. The nine entities are **fully standalone** — no shared supertype, no `@MappedSuperclass`, no discriminator, no join. `ProviderCallAudit` therefore dispatches on `Operator` with 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 the `auditExecutor` pool (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's `CompletableFuture` rather than blocking on it. Credentials matching `ProviderCallAudit.SECRETS` (Password, passkey, api-key, secret, authorization, access_token) are masked before anything is stored. +- `@Table` names must be lowercase — Postgres folds unquoted DDL identifiers to lowercase and Spring Data quotes an explicit entity name verbatim. Column names carry no `@Column` annotation, so they are derived and adapt to the dialect's casing on their own; keep it that way. - `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`):** diff --git a/build.gradle b/build.gradle index b4d941f..f321096 100644 --- a/build.gradle +++ b/build.gradle @@ -22,14 +22,12 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-webclient") - implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")/* - implementation("io.r2dbc:r2dbc-postgresql")*/ + implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive") implementation("org.springframework.boot:spring-boot-starter-validation") -// Choose ONE driver depending on your DB: - runtimeOnly("io.r2dbc:r2dbc-h2") // for in-memory + runtimeOnly("org.postgresql:postgresql") implementation("org.springframework.boot:spring-boot-starter") implementation("org.aspectj:aspectjweaver") diff --git a/src/main/java/com/test/payment/PaymentApplication.java b/src/main/java/com/test/payment/PaymentApplication.java index 49aacad..bef306b 100644 --- a/src/main/java/com/test/payment/PaymentApplication.java +++ b/src/main/java/com/test/payment/PaymentApplication.java @@ -2,12 +2,12 @@ package com.test.payment; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling -@EnableR2dbcRepositories(basePackages = "com.test.payment.repository") +@EnableJpaRepositories(basePackages = "com.test.payment.repository") public class PaymentApplication { public static void main(String[] args) { diff --git a/src/main/java/com/test/payment/configurations/AuditExecutorConfig.java b/src/main/java/com/test/payment/configurations/AuditExecutorConfig.java new file mode 100644 index 0000000..1693ef8 --- /dev/null +++ b/src/main/java/com/test/payment/configurations/AuditExecutorConfig.java @@ -0,0 +1,43 @@ +package com.test.payment.configurations; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The thread pool the provider-call audit trail is written on. Deliberately separate + * from the payment path: audit writes are best-effort bookkeeping and must never add + * latency to, or fail, a collection. + */ +@Configuration +@Slf4j +public class AuditExecutorConfig { + + /** + * Bounded queue with a discard policy — if the operator traffic ever outruns the + * database, we drop audit rows (and say so) rather than pile up heap or block a + * caller. Losing an audit row is survivable; stalling a payment is not. + */ + @Bean(destroyMethod = "shutdown") + public ExecutorService auditExecutor() { + AtomicInteger counter = new AtomicInteger(); + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 2, 4, + 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(1000), + runnable -> { + Thread thread = new Thread(runnable, "provider-audit-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }, + (runnable, pool) -> log.warn("Provider audit queue full — dropping one audit write")); + executor.allowCoreThreadTimeOut(true); + return executor; + } +} diff --git a/src/main/java/com/test/payment/configurations/DataSeeder.java b/src/main/java/com/test/payment/configurations/DataSeeder.java new file mode 100644 index 0000000..9c2e102 --- /dev/null +++ b/src/main/java/com/test/payment/configurations/DataSeeder.java @@ -0,0 +1,117 @@ +package com.test.payment.configurations; + +import com.test.payment.models.LimitPeriod; +import com.test.payment.models.Operator; +import com.test.payment.models.LimitScope; +import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.ProviderLimit; +import com.test.payment.models.Status; +import com.test.payment.repository.ProviderLimitRepository; +import com.test.payment.repository.StatusRepository; +import com.test.payment.service.ProviderMarkets; +import com.test.payment.service.StatusCatalog; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Seeds the reference data Hibernate cannot derive from the entities: the payment + * {@link Status} rows and the default provider ceilings. Every insert is guarded by + * an existence check, so edits made at runtime survive a restart. + * + *

Replaces the old DatabaseSchema/DatabaseSchemaInitializer pair — the schema + * itself is now owned by Hibernate (spring.jpa.hibernate.ddl-auto). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DataSeeder implements ApplicationRunner { + + private final StatusRepository statusRepository; + private final ProviderLimitRepository limitRepository; + private final StatusCatalog statusCatalog; + private final ProviderMarkets markets; + private final Environment environment; + + @Override + @Transactional + public void run(ApplicationArguments args) { + seedStatuses(); + seedProviderLimits(); + } + + private void seedStatuses() { + int inserted = 0; + for (StatusCatalog.StatusDefinition status : statusCatalog.defaults()) { + if (statusRepository.findByName(status.name()).isEmpty()) { + statusRepository.save(Status.builder() + .Name(status.name()) + .Description(status.description()) + .Registered(Instant.now()) + .build()); + inserted++; + } + } + statusCatalog.invalidate(); + log.info("Statuses seeded ({} inserted, {} already present)", + inserted, statusCatalog.defaults().size() - inserted); + } + + /** + * Default ceilings per operator: per-transaction, daily per payer, daily + * merchant-wide. Seeded only for the markets actually configured — AIRTEL_KE + * gets rows, AIRTEL_UG only does once someone points airtel.country at Uganda. + */ + private static final Map DEFAULT_CEILINGS = new EnumMap<>(Map.of( + Operator.MPESA, new String[]{"250000.00", "500000.00", "5000000.00"}, + Operator.AIRTEL, new String[]{"150000.00", "300000.00", "3000000.00"}, + Operator.MTN, new String[]{"5000.00", "10000.00", "100000.00"})); + + private void seedProviderLimits() { + int inserted = 0; + for (PaymentProviderType provider : configuredProviders()) { + String[] ceilings = DEFAULT_CEILINGS.get(provider.operator()); + // the currency the service actually sends, so ceilings and charges agree + String currency = environment.getProperty( + provider.operator().name().toLowerCase() + ".currency", provider.currency()); + inserted += seedLimit(provider, LimitPeriod.PER_TRANSACTION, LimitScope.PER_PAYER, ceilings[0], currency); + inserted += seedLimit(provider, LimitPeriod.DAILY, LimitScope.PER_PAYER, ceilings[1], currency); + inserted += seedLimit(provider, LimitPeriod.DAILY, LimitScope.MERCHANT, ceilings[2], currency); + } + log.info("Default provider limits seeded ({} inserted)", inserted); + } + + /** The market-qualified providers this deployment is wired for. */ + private List configuredProviders() { + return Arrays.stream(Operator.values()).map(markets::resolve).toList(); + } + + private int seedLimit(PaymentProviderType provider, LimitPeriod period, LimitScope scope, + String maxAmount, String currency) { + if (limitRepository.findByProviderAndPeriodAndScope(provider, period.name(), scope.name()).isPresent()) { + return 0; + } + limitRepository.save(ProviderLimit.builder() + .Provider(provider) + .Period(period.name()) + .Scope(scope.name()) + .MaxAmount(new BigDecimal(maxAmount)) + .Currency(currency) + .Active(Boolean.TRUE) + .CreatedAt(LocalDateTime.now()) + .build()); + return 1; + } +} diff --git a/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java b/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java deleted file mode 100644 index d0daca4..0000000 --- a/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.test.payment.configurations; - -import com.test.payment.models.DatabaseSchema; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.r2dbc.core.DatabaseClient; -import reactor.core.publisher.Flux; - -/** - * Creates the schema from DatabaseSchema.STATEMENTS at startup - * (replaces spring.sql.init + schema.sql). - */ -@Configuration -@Slf4j -public class DatabaseSchemaInitializer { - - @Bean - public org.springframework.beans.factory.InitializingBean schemaInitializer(DatabaseClient databaseClient) { - return () -> Flux.fromIterable(DatabaseSchema.STATEMENTS) - .concatMap(statement -> databaseClient.sql(statement).then()) - .doOnComplete(() -> log.info("Database schema initialized ({} statements)", DatabaseSchema.STATEMENTS.size())) - .thenMany(Flux.fromIterable(DatabaseSchema.SEED_STATEMENTS)) - .concatMap(statement -> databaseClient.sql(statement).then()) - .doOnComplete(() -> log.info("Default provider limits seeded")) - .then() - .block(); - } -} diff --git a/src/main/java/com/test/payment/controller/PaymentsController.java b/src/main/java/com/test/payment/controller/PaymentsController.java index a65124c..60a7575 100644 --- a/src/main/java/com/test/payment/controller/PaymentsController.java +++ b/src/main/java/com/test/payment/controller/PaymentsController.java @@ -1,6 +1,7 @@ package com.test.payment.controller; import com.test.payment.dto.ProviderLimitDto; +import com.test.payment.models.PaymentProviderType; import com.test.payment.models.ProviderLimit; import com.test.payment.models.Transaction; import com.test.payment.service.PaymentLifecycleService; @@ -13,9 +14,13 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Arrays; + @RestController @RequestMapping("/api/payments") @RequiredArgsConstructor @@ -25,19 +30,19 @@ public class PaymentsController { private final PaymentLimitService limits; /** - * All consolidated transactions across providers; optional ?provider=MPESA|AIRTEL|MTN filter. + * All consolidated transactions across providers; optional ?provider=MPESA_KE|AIRTEL_KE|MTN_UG filter. */ @GetMapping("/transactions") public Flux transactions(@RequestParam(required = false) String provider) { - return lifecycle.listTransactions(provider == null ? null : provider.toUpperCase()); + return lifecycle.listTransactions(parseProvider(provider)); } /** - * Configured payment ceilings; optional ?provider=MPESA|AIRTEL|MTN filter. + * Configured payment ceilings; optional ?provider=MPESA_KE|AIRTEL_KE|MTN_UG filter. */ @GetMapping("/limits") public Flux limits(@RequestParam(required = false) String provider) { - return limits.list(provider == null ? null : provider.toUpperCase()); + return limits.list(parseProvider(provider)); } /** @@ -47,4 +52,17 @@ public class PaymentsController { public Mono setLimit(@Valid @RequestBody ProviderLimitDto request) { return limits.upsert(request); } + + /** Null (meaning "every provider") stays null; anything else must name a real provider. */ + private PaymentProviderType parseProvider(String provider) { + if (provider == null) { + return null; + } + try { + return PaymentProviderType.valueOf(provider.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "provider must be one of " + Arrays.toString(PaymentProviderType.values())); + } + } } diff --git a/src/main/java/com/test/payment/dto/PaymentResultDto.java b/src/main/java/com/test/payment/dto/PaymentResultDto.java index c2f18de..972af5a 100644 --- a/src/main/java/com/test/payment/dto/PaymentResultDto.java +++ b/src/main/java/com/test/payment/dto/PaymentResultDto.java @@ -1,5 +1,6 @@ package com.test.payment.dto; +import com.test.payment.models.PaymentProviderType; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,7 +13,7 @@ import lombok.NoArgsConstructor; public class PaymentResultDto { private Long initiationId; - private String provider; + private PaymentProviderType provider; private String status; private String providerReference; private String secondaryReference; diff --git a/src/main/java/com/test/payment/dto/TransactionStatusDto.java b/src/main/java/com/test/payment/dto/TransactionStatusDto.java index 987b556..be72535 100644 --- a/src/main/java/com/test/payment/dto/TransactionStatusDto.java +++ b/src/main/java/com/test/payment/dto/TransactionStatusDto.java @@ -1,5 +1,6 @@ package com.test.payment.dto; +import com.test.payment.models.PaymentProviderType; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,7 +16,7 @@ import java.time.LocalDateTime; public class TransactionStatusDto { private Long initiationId; - private String provider; + private PaymentProviderType provider; private String providerReference; private String secondaryReference; private String status; diff --git a/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java index 150b51c..8da73e0 100644 --- a/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java +++ b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java @@ -1,7 +1,6 @@ package com.test.payment.jobs; -import com.test.payment.models.TransactionStatus; -import com.test.payment.repository.PaymentInitiationRepository; +import com.test.payment.models.PaymentProviderType; import com.test.payment.service.PaymentLifecycleService; import com.test.payment.service.PaymentProviderService; import lombok.extern.slf4j.Slf4j; @@ -28,17 +27,14 @@ import java.util.stream.Collectors; @Slf4j public class PaymentReconciliationJob { - private final PaymentInitiationRepository initiationRepository; private final PaymentLifecycleService lifecycle; private final Environment environment; - private final Map servicesByProvider; + private final Map servicesByProvider; private final AtomicBoolean running = new AtomicBoolean(false); - public PaymentReconciliationJob(PaymentInitiationRepository initiationRepository, - PaymentLifecycleService lifecycle, + public PaymentReconciliationJob(PaymentLifecycleService lifecycle, Environment environment, List providerServices) { - this.initiationRepository = initiationRepository; this.lifecycle = lifecycle; this.environment = environment; this.servicesByProvider = providerServices.stream() @@ -55,7 +51,7 @@ public class PaymentReconciliationJob { environment.getProperty("payments.reconciliation.pending-age", "5m")); LocalDateTime cutoff = LocalDateTime.now().minus(pendingAge); - initiationRepository.findByStatusAndCreatedAtBefore(TransactionStatus.PENDING.name(), cutoff) + lifecycle.findPendingOlderThan(cutoff) .concatMap(initiation -> { PaymentProviderService service = servicesByProvider.get(initiation.getProvider()); if (service == null) { diff --git a/src/main/java/com/test/payment/models/AirtelPaymentCallback.java b/src/main/java/com/test/payment/models/AirtelPaymentCallback.java new file mode 100644 index 0000000..5d1b303 --- /dev/null +++ b/src/main/java/com/test/payment/models/AirtelPaymentCallback.java @@ -0,0 +1,81 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * The Airtel Money result callback that resolved an initiation. Deduplicated: one row per + * initiation, so a repeated callback is ignored rather than reapplied. The complete, + * duplicate-inclusive record lives in airtel_callback_responses instead. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class AirtelPaymentCallback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this resolves. Unique: this is what makes the dedup work. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + @Column(columnDefinition = "TEXT") + private String RawPayload; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/AirtelPaymentResponse.java b/src/main/java/com/test/payment/models/AirtelPaymentResponse.java new file mode 100644 index 0000000..e3407b5 --- /dev/null +++ b/src/main/java/com/test/payment/models/AirtelPaymentResponse.java @@ -0,0 +1,73 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * Airtel Money's answer to an initiation. providerReference is the generated ATL transaction id. + * + *

One row per initiation. Independent of the other operators' response tables — + * the lifecycle reads it through PaymentLifecycleStore's StoredResponse view. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class AirtelPaymentResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this answers. Unique: one response per initiation. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Lookup key for callbacks and status checks. */ + @Column(unique = true, length = 100) + private String ProviderReference; + + @Column(length = 100) + private String SecondaryReference; + + @Column(length = 30) + private String ResponseCode; + + @Column(length = 255) + private String ResponseDescription; + + @Column(length = 255) + private String CustomerMessage; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/Country.java b/src/main/java/com/test/payment/models/Country.java new file mode 100644 index 0000000..72bf094 --- /dev/null +++ b/src/main/java/com/test/payment/models/Country.java @@ -0,0 +1,59 @@ +package com.test.payment.models; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The markets this service can transact in. The ISO-4217 currency and the E.164 + * dialing prefix travel with the country so a provider never has to guess either — + * see {@link PaymentProviderType#currencyFor(Country)}. + */ +public enum Country { + + KE("Kenya", "KES", "254"), + TZ("Tanzania", "TZS", "255"), + UG("Uganda", "UGX", "256"), + RW("Rwanda", "RWF", "250"), + GH("Ghana", "GHS", "233"), + ZM("Zambia", "ZMW", "260"), + CM("Cameroon", "XAF", "237"), + CI("Côte d'Ivoire", "XOF", "225"); + + private final String displayName; + private final String currency; + private final String dialingCode; + + Country(String displayName, String currency, String dialingCode) { + this.displayName = displayName; + this.currency = currency; + this.dialingCode = dialingCode; + } + + public String displayName() { + return displayName; + } + + /** ISO-4217 code of the country's own currency. */ + public String currency() { + return currency; + } + + /** E.164 country calling code, without the leading '+'. */ + public String dialingCode() { + return dialingCode; + } + + /** True when an MSISDN in international format belongs to this country. */ + public boolean owns(String msisdn) { + return msisdn != null && msisdn.startsWith(dialingCode); + } + + /** Case-insensitive lookup by ISO-3166 alpha-2 code; empty when unknown. */ + public static Optional of(String isoCode) { + return isoCode == null + ? Optional.empty() + : Arrays.stream(values()) + .filter(country -> country.name().equalsIgnoreCase(isoCode.trim())) + .findFirst(); + } +} diff --git a/src/main/java/com/test/payment/models/DatabaseSchema.java b/src/main/java/com/test/payment/models/DatabaseSchema.java deleted file mode 100644 index b979b38..0000000 --- a/src/main/java/com/test/payment/models/DatabaseSchema.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.test.payment.models; - -import java.util.List; - -/** - * The database schema as code — executed statement-by-statement at startup by - * DatabaseSchemaInitializer (replaces the old classpath schema.sql). - */ -public final class DatabaseSchema { - - private DatabaseSchema() { - } - - public static final List STATEMENTS = List.of( - """ - CREATE TABLE IF NOT EXISTS provider_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - provider VARCHAR(20) NOT NULL, - access_token VARCHAR(512) NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL - ) - """, - """ - CREATE TABLE IF NOT EXISTS payment_initiations ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - provider VARCHAR(20) NOT NULL, - phone_number VARCHAR(15) NOT NULL, - amount DECIMAL(10,2) NOT NULL, - account_reference VARCHAR(50), - transaction_desc VARCHAR(100), - status VARCHAR(20) NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_initiations_status_created - ON payment_initiations (status, created_at) - """, - // One response per initiation (UNIQUE on initiation_id enforces the 1:1 link). - // provider_reference is the ID used for callbacks/status checks - // (M-Pesa CheckoutRequestID, Airtel transaction id, MTN X-Reference-Id). - """ - CREATE TABLE IF NOT EXISTS payment_responses ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - initiation_id BIGINT NOT NULL UNIQUE, - provider VARCHAR(20) NOT NULL, - provider_reference VARCHAR(100) UNIQUE, - secondary_reference VARCHAR(100), - response_code VARCHAR(30), - response_description VARCHAR(255), - customer_message VARCHAR(255), - created_at TIMESTAMP NOT NULL, - CONSTRAINT fk_response_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id) - ) - """, - // One callback per initiation (UNIQUE on initiation_id enforces the 1:1 link) - """ - CREATE TABLE IF NOT EXISTS payment_callbacks ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - initiation_id BIGINT NOT NULL UNIQUE, - provider VARCHAR(20) NOT NULL, - provider_reference VARCHAR(100), - result_code VARCHAR(30), - result_desc VARCHAR(255), - receipt_number VARCHAR(50), - amount DECIMAL(10,2), - phone_number VARCHAR(15), - transaction_date VARCHAR(20), - raw_payload CLOB, - created_at TIMESTAMP NOT NULL, - CONSTRAINT fk_callback_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id) - ) - """, - // Consolidated transaction record, written when an initiation reaches a - // terminal state (SUCCESS/FAILED). UNIQUE initiation_id: one per initiation. - """ - CREATE TABLE IF NOT EXISTS transactions ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - initiation_id BIGINT NOT NULL UNIQUE, - provider VARCHAR(20) NOT NULL, - provider_reference VARCHAR(100), - secondary_reference VARCHAR(100), - phone_number VARCHAR(15), - amount DECIMAL(10,2), - account_reference VARCHAR(50), - status VARCHAR(20) NOT NULL, - result_code VARCHAR(30), - result_desc VARCHAR(255), - receipt_number VARCHAR(50), - transaction_date VARCHAR(20), - resolved_by VARCHAR(20), - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP, - CONSTRAINT fk_transaction_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id) - ) - """, - // Configurable payment ceilings: one row per provider per period - // (PER_TRANSACTION, DAILY, MONTHLY — see LimitPeriod) per scope - // (PER_PAYER, MERCHANT — see LimitScope). - """ - CREATE TABLE IF NOT EXISTS provider_limits ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - provider VARCHAR(20) NOT NULL, - period VARCHAR(20) NOT NULL, - scope VARCHAR(20) NOT NULL DEFAULT 'PER_PAYER', - max_amount DECIMAL(14,2) NOT NULL, - currency VARCHAR(5), - active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP, - CONSTRAINT uq_provider_limit UNIQUE (provider, period, scope) - ) - """ - ); - - /** - * Default ceilings, inserted only when that provider/period/scope triple is - * absent, so limits edited at runtime survive a restart on a persistent database. - */ - public static final List SEED_STATEMENTS = List.of( - seedLimit("MPESA", "PER_TRANSACTION", "PER_PAYER", "250000.00", "KES"), - seedLimit("MPESA", "DAILY", "PER_PAYER", "500000.00", "KES"), - seedLimit("AIRTEL", "PER_TRANSACTION", "PER_PAYER", "150000.00", "KES"), - seedLimit("AIRTEL", "DAILY", "PER_PAYER", "300000.00", "KES"), - // MoMo sandbox prices in EUR - seedLimit("MTN", "PER_TRANSACTION", "PER_PAYER", "5000.00", "EUR"), - seedLimit("MTN", "DAILY", "PER_PAYER", "10000.00", "EUR"), - // Aggregate merchant-wide daily exposure, across all payers on the provider - seedLimit("MPESA", "DAILY", "MERCHANT", "5000000.00", "KES"), - seedLimit("AIRTEL", "DAILY", "MERCHANT", "3000000.00", "KES"), - seedLimit("MTN", "DAILY", "MERCHANT", "100000.00", "EUR") - ); - - private static String seedLimit(String provider, String period, String scope, - String maxAmount, String currency) { - return """ - INSERT INTO provider_limits (provider, period, scope, max_amount, currency, active, created_at) - SELECT '%s', '%s', '%s', %s, '%s', TRUE, CURRENT_TIMESTAMP FROM DUAL - WHERE NOT EXISTS ( - SELECT 1 FROM provider_limits - WHERE provider = '%s' AND period = '%s' AND scope = '%s' - ) - """.formatted(provider, period, scope, maxAmount, currency, provider, period, scope); - } -} diff --git a/src/main/java/com/test/payment/models/MpesaPaymentCallback.java b/src/main/java/com/test/payment/models/MpesaPaymentCallback.java new file mode 100644 index 0000000..7599c91 --- /dev/null +++ b/src/main/java/com/test/payment/models/MpesaPaymentCallback.java @@ -0,0 +1,81 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * The M-Pesa result callback that resolved an initiation. Deduplicated: one row per + * initiation, so a repeated callback is ignored rather than reapplied. The complete, + * duplicate-inclusive record lives in mpesa_callback_responses instead. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class MpesaPaymentCallback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this resolves. Unique: this is what makes the dedup work. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + @Column(columnDefinition = "TEXT") + private String RawPayload; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/MpesaPaymentResponse.java b/src/main/java/com/test/payment/models/MpesaPaymentResponse.java new file mode 100644 index 0000000..eaf3908 --- /dev/null +++ b/src/main/java/com/test/payment/models/MpesaPaymentResponse.java @@ -0,0 +1,73 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * M-Pesa's answer to an initiation. providerReference is the CheckoutRequestID; secondaryReference the MerchantRequestID. + * + *

One row per initiation. Independent of the other operators' response tables — + * the lifecycle reads it through PaymentLifecycleStore's StoredResponse view. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class MpesaPaymentResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this answers. Unique: one response per initiation. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Lookup key for callbacks and status checks. */ + @Column(unique = true, length = 100) + private String ProviderReference; + + @Column(length = 100) + private String SecondaryReference; + + @Column(length = 30) + private String ResponseCode; + + @Column(length = 255) + private String ResponseDescription; + + @Column(length = 255) + private String CustomerMessage; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/MtnPaymentCallback.java b/src/main/java/com/test/payment/models/MtnPaymentCallback.java new file mode 100644 index 0000000..fee8154 --- /dev/null +++ b/src/main/java/com/test/payment/models/MtnPaymentCallback.java @@ -0,0 +1,81 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * The MTN MoMo result callback that resolved an initiation. Deduplicated: one row per + * initiation, so a repeated callback is ignored rather than reapplied. The complete, + * duplicate-inclusive record lives in mtn_callback_responses instead. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class MtnPaymentCallback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this resolves. Unique: this is what makes the dedup work. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + @Column(columnDefinition = "TEXT") + private String RawPayload; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/MtnPaymentResponse.java b/src/main/java/com/test/payment/models/MtnPaymentResponse.java new file mode 100644 index 0000000..68bb5a5 --- /dev/null +++ b/src/main/java/com/test/payment/models/MtnPaymentResponse.java @@ -0,0 +1,73 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * MTN MoMo's answer to an initiation. providerReference is the generated X-Reference-Id UUID. + * + *

One row per initiation. Independent of the other operators' response tables — + * the lifecycle reads it through PaymentLifecycleStore's StoredResponse view. + */ +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = "Initiation") +public class MtnPaymentResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** The attempt this answers. Unique: one response per initiation. */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Lookup key for callbacks and status checks. */ + @Column(unique = true, length = 100) + private String ProviderReference; + + @Column(length = 100) + private String SecondaryReference; + + @Column(length = 30) + private String ResponseCode; + + @Column(length = 255) + private String ResponseDescription; + + @Column(length = 255) + private String CustomerMessage; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } +} diff --git a/src/main/java/com/test/payment/models/Operator.java b/src/main/java/com/test/payment/models/Operator.java new file mode 100644 index 0000000..ee0009a --- /dev/null +++ b/src/main/java/com/test/payment/models/Operator.java @@ -0,0 +1,43 @@ +package com.test.payment.models; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +/** + * A mobile-money operator — the integration itself (one client, one token service, + * one set of Resilience4j instances), independent of the market it is used in. + * The market-qualified identity a payment is actually recorded against is + * {@link PaymentProviderType} (AIRTEL_KE, AIRTEL_UG, ...). + */ +public enum Operator { + + MPESA(EnumSet.of(Country.KE, Country.TZ)), + AIRTEL(EnumSet.of(Country.KE, Country.UG, Country.TZ, Country.RW, Country.ZM)), + MTN(EnumSet.of(Country.UG, Country.GH, Country.CM, Country.CI, Country.RW, Country.ZM)); + + private final Set countries; + + Operator(Set countries) { + this.countries = Collections.unmodifiableSet(countries); + } + + /** Every market this operator serves. */ + public Set countries() { + return countries; + } + + public boolean supports(Country country) { + return country != null && countries.contains(country); + } + + /** + * @throws IllegalArgumentException when this operator does not trade in the market + */ + public void requireSupported(Country country) { + if (!supports(country)) { + throw new IllegalArgumentException("%s does not operate in %s — supported markets: %s".formatted( + name(), country == null ? "an unknown country" : country.displayName(), countries)); + } + } +} diff --git a/src/main/java/com/test/payment/models/PaymentCallback.java b/src/main/java/com/test/payment/models/PaymentCallback.java deleted file mode 100644 index f371279..0000000 --- a/src/main/java/com/test/payment/models/PaymentCallback.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.test.payment.models; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -@Table("PAYMENT_CALLBACKS") -public class PaymentCallback { - - @Id - private Long id; - private Long initiationId; - private String provider; - private String providerReference; - private String resultCode; - private String resultDesc; - private String receiptNumber; - private BigDecimal amount; - private String phoneNumber; - private String transactionDate; - private String rawPayload; - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/test/payment/models/PaymentInitiation.java b/src/main/java/com/test/payment/models/PaymentInitiation.java index 642b27e..c11bccd 100644 --- a/src/main/java/com/test/payment/models/PaymentInitiation.java +++ b/src/main/java/com/test/payment/models/PaymentInitiation.java @@ -1,30 +1,70 @@ package com.test.payment.models; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; +import lombok.Setter; +import lombok.ToString; import java.math.BigDecimal; import java.time.LocalDateTime; -@Data +@Entity +@Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor -@Table("PAYMENT_INITIATIONS") +@ToString public class PaymentInitiation { @Id - private Long id; - private String provider; - private String phoneNumber; - private BigDecimal amount; - private String accountReference; - private String transactionDesc; - private String status; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(nullable = false, length = 15) + private String PhoneNumber; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 5000) + private String AccountReference; + + @Column(length = 10000) + private String TransactionDesc; + + // Left at @ManyToOne's default EAGER: every read of an initiation reports its + // status, and the reactive layer touches it after the transaction has closed, + // where a lazy proxy would already be detached. + @ManyToOne(targetEntity = Status.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Status", nullable = false) + private Status Status; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + private LocalDateTime UpdatedAt; + + /** The status name ("Pending", "Success", ...), or null when unset. */ + public String statusName() { + return Status == null ? null : Status.getName(); + } } diff --git a/src/main/java/com/test/payment/models/PaymentProviderType.java b/src/main/java/com/test/payment/models/PaymentProviderType.java index 05e619b..4db4ed7 100644 --- a/src/main/java/com/test/payment/models/PaymentProviderType.java +++ b/src/main/java/com/test/payment/models/PaymentProviderType.java @@ -1,7 +1,84 @@ package com.test.payment.models; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * A provider as payments are actually recorded against it: an {@link Operator} in a + * specific market. Airtel in Kenya and Airtel in Uganda are different float pools, + * different currencies and different ceilings, so they are different providers here + * — AIRTEL_KE and AIRTEL_UG — even though they share one client and one token. + * + *

The enum name is what lands in the {@code provider} column and in every + * provider-scoped lookup (limits, reconciliation dispatch, callback matching). + */ public enum PaymentProviderType { - MPESA, - AIRTEL, - MTN + + MPESA_KE(Operator.MPESA, Country.KE), + MPESA_TZ(Operator.MPESA, Country.TZ), + + AIRTEL_KE(Operator.AIRTEL, Country.KE), + AIRTEL_UG(Operator.AIRTEL, Country.UG), + AIRTEL_TZ(Operator.AIRTEL, Country.TZ), + AIRTEL_RW(Operator.AIRTEL, Country.RW), + AIRTEL_ZM(Operator.AIRTEL, Country.ZM), + + MTN_UG(Operator.MTN, Country.UG), + MTN_GH(Operator.MTN, Country.GH), + MTN_CM(Operator.MTN, Country.CM), + MTN_CI(Operator.MTN, Country.CI), + MTN_RW(Operator.MTN, Country.RW), + MTN_ZM(Operator.MTN, Country.ZM); + + private final Operator operator; + private final Country country; + + PaymentProviderType(Operator operator, Country country) { + this.operator = operator; + this.country = country; + } + + public Operator operator() { + return operator; + } + + public Country country() { + return country; + } + + /** + * The currency this provider settles in. Sandbox environments sometimes disagree + * (MTN's only prices in EUR), which is why the provider services still let + * {@code .currency} override it. + */ + public String currency() { + return country.currency(); + } + + /** Every market this operator is wired up for. */ + public static List forOperator(Operator operator) { + return Arrays.stream(values()).filter(type -> type.operator == operator).toList(); + } + + /** Every operator able to collect a payment in this market. */ + public static List forCountry(Country country) { + return Arrays.stream(values()).filter(type -> type.country == country).toList(); + } + + public static Optional of(Operator operator, Country country) { + return Arrays.stream(values()) + .filter(type -> type.operator == operator && type.country == country) + .findFirst(); + } + + /** + * @throws IllegalStateException when the operator is not wired up for that market — + * a misconfiguration, caught at startup rather than mid-payment + */ + public static PaymentProviderType require(Operator operator, Country country) { + operator.requireSupported(country); + return of(operator, country).orElseThrow(() -> new IllegalStateException( + "No provider constant for %s in %s".formatted(operator, country))); + } } diff --git a/src/main/java/com/test/payment/models/PaymentResponse.java b/src/main/java/com/test/payment/models/PaymentResponse.java deleted file mode 100644 index 71630f8..0000000 --- a/src/main/java/com/test/payment/models/PaymentResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.test.payment.models; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; - -import java.time.LocalDateTime; - -/** - * The provider's answer to an initiation. providerReference is the ID later used - * for callbacks and status checks (M-Pesa CheckoutRequestID, Airtel transaction id, - * MTN X-Reference-Id); secondaryReference is any additional provider ID - * (M-Pesa MerchantRequestID). - */ -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -@Table("PAYMENT_RESPONSES") -public class PaymentResponse { - - @Id - private Long id; - private Long initiationId; - private String provider; - private String providerReference; - private String secondaryReference; - private String responseCode; - private String responseDescription; - private String customerMessage; - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/test/payment/models/ProviderLimit.java b/src/main/java/com/test/payment/models/ProviderLimit.java index f6924c8..738b291 100644 --- a/src/main/java/com/test/payment/models/ProviderLimit.java +++ b/src/main/java/com/test/payment/models/ProviderLimit.java @@ -1,34 +1,66 @@ package com.test.payment.models; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; +import lombok.Setter; +import lombok.ToString; import java.math.BigDecimal; import java.time.LocalDateTime; /** - * A configurable payment ceiling for one provider over one {@link LimitPeriod}. - * Seeded with defaults at startup and editable at runtime via /api/payments/limits. + * A configurable payment ceiling for one provider over one {@link LimitPeriod}, + * for one {@link LimitScope}. Seeded with defaults at startup and editable at + * runtime via /api/payments/limits. */ -@Data +@Entity +@Table(name = "provider_limits", + uniqueConstraints = @UniqueConstraint(name = "uq_provider_limit", + columnNames = {"provider", "period", "scope"})) +@Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor -@Table("PROVIDER_LIMITS") +@ToString public class ProviderLimit { @Id - private Long id; - private String provider; - private String period; - private String scope; - private BigDecimal maxAmount; - private String currency; - private Boolean active; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} \ No newline at end of file + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(nullable = false, length = 20) + private String Period; + + @Column(nullable = false, length = 20) + private String Scope; + + @Column(nullable = false, precision = 14, scale = 2) + private BigDecimal MaxAmount; + + @Column(length = 5) + private String Currency; + + @Column(nullable = false) + private Boolean Active; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + private LocalDateTime UpdatedAt; +} diff --git a/src/main/java/com/test/payment/models/ProviderToken.java b/src/main/java/com/test/payment/models/ProviderToken.java index 31012f5..4df82fc 100644 --- a/src/main/java/com/test/payment/models/ProviderToken.java +++ b/src/main/java/com/test/payment/models/ProviderToken.java @@ -1,25 +1,49 @@ package com.test.payment.models; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; +import lombok.Setter; +import lombok.ToString; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; -@Data +@Entity +@Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor -@Table("PROVIDER_TOKENS") +@ToString public class ProviderToken { @Id - private Long id; - private String provider; - private String accessToken; - private LocalDateTime expiresAt; - private LocalDateTime createdAt; + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Operator Provider; + + @Column(nullable = false, columnDefinition = "TEXT") + private String AccessToken; + + @Column(nullable = false) + private LocalDateTime ExpiresAt; + + @Column(nullable = false) + private LocalDateTime CreatedAt; } diff --git a/src/main/java/com/test/payment/models/Status.java b/src/main/java/com/test/payment/models/Status.java new file mode 100644 index 0000000..bc64481 --- /dev/null +++ b/src/main/java/com/test/payment/models/Status.java @@ -0,0 +1,49 @@ +package com.test.payment.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * The lifecycle state of a payment, as a lookup row rather than an enum, so states + * can be added or described without a redeploy. Referenced many-to-one by + * {@link PaymentInitiation} and {@link Transaction}; seeded by StatusSeeder. + */ +@Entity +@Table(name = "statuses") +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class Status { + + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "status_sequencer") + @SequenceGenerator(name = "status_sequencer", sequenceName = "status_sequencer", + initialValue = 10001, allocationSize = 1) + private Long Id; + + @Column(nullable = false, unique = true, updatable = false, length = 20) + private String Name; + + @Column(nullable = false, length = 2000) + private String Description; + + @Column(nullable = false, updatable = false) + private Instant Registered; +} diff --git a/src/main/java/com/test/payment/models/Transaction.java b/src/main/java/com/test/payment/models/Transaction.java index 815d51c..d0be5dd 100644 --- a/src/main/java/com/test/payment/models/Transaction.java +++ b/src/main/java/com/test/payment/models/Transaction.java @@ -1,42 +1,144 @@ package com.test.payment.models; +import com.test.payment.models.audit.AirtelRequest; +import com.test.payment.models.audit.MpesaRequest; +import com.test.payment.models.audit.MtnRequest; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.relational.core.mapping.Table; +import lombok.Setter; +import lombok.ToString; import java.math.BigDecimal; import java.time.LocalDateTime; -/** - * Consolidated record written when a payment attempt reaches a terminal state - * (SUCCESS or FAILED) — one row per initiation, whatever path resolved it - * (callback, status query, immediate rejection, or reconciliation timeout). - */ -@Data + +@Entity +@Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor -@Table("TRANSACTIONS") +@ToString(exclude = {"Initiation", "MpesaRequest", "AirtelRequest", "MtnRequest", + "MpesaCallback", "AirtelCallback", "MtnCallback"}) public class Transaction { @Id - private Long id; - private Long initiationId; - private String provider; - private String providerReference; - private String secondaryReference; - private String phoneNumber; - private BigDecimal amount; - private String accountReference; - private String status; - private String resultCode; - private String resultDesc; - private String receiptNumber; - private String transactionDate; - private String resolvedBy; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** + * The attempt this consolidates. Unique: one transaction per initiation, + * whichever path (callback, query, rejection, reconciliation) resolved it. + */ + @ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Initiation", nullable = false, unique = true) + private PaymentInitiation Initiation; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** + * The outbound call that started this payment, and through it everything the + * operator returned. Exactly one of the three is ever set — whichever operator + * owns the row — because the request tables are per operator and share no + * supertype. + */ + @ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "MpesaRequest") + private MpesaRequest MpesaRequest; + + @ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "AirtelRequest") + private AirtelRequest AirtelRequest; + + @ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "MtnRequest") + private MtnRequest MtnRequest; + + /** The callback that resolved the payment, again one per operator. */ + @ManyToOne(targetEntity = MpesaPaymentCallback.class, fetch = FetchType.LAZY) + @JoinColumn(name = "MpesaCallback") + private MpesaPaymentCallback MpesaCallback; + + @ManyToOne(targetEntity = AirtelPaymentCallback.class, fetch = FetchType.LAZY) + @JoinColumn(name = "AirtelCallback") + private AirtelPaymentCallback AirtelCallback; + + @ManyToOne(targetEntity = MtnPaymentCallback.class, fetch = FetchType.LAZY) + @JoinColumn(name = "MtnCallback") + private MtnPaymentCallback MtnCallback; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 100) + private String SecondaryReference; + + @Column(length = 15) + private String PhoneNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 50) + private String AccountReference; + + @ManyToOne(targetEntity = Status.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Status", nullable = false) + private Status Status; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(length = 20) + private String TransactionDate; + + /** What resolved the payment: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION. Null while open. */ + @Column(length = 20) + private String ResolvedBy; + + @Column(nullable = false) + private LocalDateTime CreatedAt; + + private LocalDateTime UpdatedAt; + + /** True once the outbound request has been recorded against this transaction. */ + public boolean hasRequest() { + return MpesaRequest != null || AirtelRequest != null || MtnRequest != null; + } + + /** True once a result callback has been tied to this transaction. */ + public boolean hasCallback() { + return MpesaCallback != null || AirtelCallback != null || MtnCallback != null; + } + + /** The status name ("Pending", "Paid", ...), or null when unset. */ + public String statusName() { + return Status == null ? null : Status.getName(); + } + + /** Convenience for logging and DTOs that only need the initiation's id. */ + public Long initiationId() { + return Initiation == null ? null : Initiation.getId(); + } } diff --git a/src/main/java/com/test/payment/models/TransactionStatus.java b/src/main/java/com/test/payment/models/TransactionStatus.java deleted file mode 100644 index d5df8db..0000000 --- a/src/main/java/com/test/payment/models/TransactionStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.test.payment.models; - -public enum TransactionStatus { - PENDING, - SUCCESS, - FAILED -} diff --git a/src/main/java/com/test/payment/models/audit/AirtelCallbackResponse.java b/src/main/java/com/test/payment/models/audit/AirtelCallbackResponse.java new file mode 100644 index 0000000..7b25eee --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/AirtelCallbackResponse.java @@ -0,0 +1,78 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * An asynchronous result callback from Airtel Money, stored whole alongside the fields + * worth querying on, and tied back to the call that caused it. Unlike + * PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail + * that silently drops repeats is not one. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class AirtelCallbackResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Null when the callback could not be matched to an outbound request. */ + @ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request") + private AirtelRequest Request; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + /** The callback exactly as it arrived. */ + @Column(columnDefinition = "TEXT") + private String RawPayload; + + /** False when no matching request could be found — an orphan worth alerting on. */ + @Column(nullable = false) + private boolean Matched; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/AirtelRequest.java b/src/main/java/com/test/payment/models/audit/AirtelRequest.java new file mode 100644 index 0000000..c3aaf85 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/AirtelRequest.java @@ -0,0 +1,68 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * One outbound call to Airtel Money, recorded verbatim: what we sent, where, and on whose + * behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path + * waits for this row. + */ +@Entity +@Getter +@Setter +@ToString +public class AirtelRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Market-qualified provider, e.g. AIRTEL_KE. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Which call this was: USSD_PUSH, STATUS_QUERY, TOKEN. */ + @Column(nullable = false, length = 40) + private String Operation; + + /** + * The payment attempt this call belongs to; null for calls with no payment + * (token fetches). A string, not the numeric id, so it can carry a NanoID. + */ + @Column(length = 36) + private String InitiationId; + + /** + * The provider's own reference once known, so a later callback can be tied back + * to the exact call that produced it. + */ + @Column(length = 100) + private String ProviderReference; + + @Column(length = 10) + private String HttpMethod; + + @Column(length = 512) + private String Url; + + /** Outbound body as sent, JSON. Credentials are redacted before this is stored. */ + @Column(columnDefinition = "TEXT") + private String RequestBody; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/AirtelResponse.java b/src/main/java/com/test/payment/models/audit/AirtelResponse.java new file mode 100644 index 0000000..6ff4a78 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/AirtelResponse.java @@ -0,0 +1,52 @@ +package com.test.payment.models.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * What Airtel Money answered a AirtelRequest with — including the failures, which is + * usually what an audit is actually needed for. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class AirtelResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request", nullable = false) + private AirtelRequest Request; + + /** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */ + private Integer HttpStatus; + + /** Response body verbatim, JSON. */ + @Column(columnDefinition = "TEXT") + private String ResponseBody; + + /** Exception summary when the call failed outright. */ + @Column(length = 512) + private String Error; + + /** Round-trip time, for spotting Airtel Money degrading before it starts erroring. */ + private Long DurationMs; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MpesaCallbackResponse.java b/src/main/java/com/test/payment/models/audit/MpesaCallbackResponse.java new file mode 100644 index 0000000..9206abc --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MpesaCallbackResponse.java @@ -0,0 +1,78 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * An asynchronous result callback from M-Pesa, stored whole alongside the fields + * worth querying on, and tied back to the call that caused it. Unlike + * PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail + * that silently drops repeats is not one. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class MpesaCallbackResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Null when the callback could not be matched to an outbound request. */ + @ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request") + private MpesaRequest Request; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + /** The callback exactly as it arrived. */ + @Column(columnDefinition = "TEXT") + private String RawPayload; + + /** False when no matching request could be found — an orphan worth alerting on. */ + @Column(nullable = false) + private boolean Matched; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MpesaRequest.java b/src/main/java/com/test/payment/models/audit/MpesaRequest.java new file mode 100644 index 0000000..3bb2422 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MpesaRequest.java @@ -0,0 +1,68 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * One outbound call to M-Pesa, recorded verbatim: what we sent, where, and on whose + * behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path + * waits for this row. + */ +@Entity +@Getter +@Setter +@ToString +public class MpesaRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Market-qualified provider, e.g. MPESA_KE. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Which call this was: STK_PUSH, STK_QUERY, TOKEN. */ + @Column(nullable = false, length = 40) + private String Operation; + + /** + * The payment attempt this call belongs to; null for calls with no payment + * (token fetches). A string, not the numeric id, so it can carry a NanoID. + */ + @Column(length = 5000) + private String InitiationId; + + /** + * The provider's own reference once known, so a later callback can be tied back + * to the exact call that produced it. + */ + @Column(length = 100) + private String ProviderReference; + + @Column(length = 10) + private String HttpMethod; + + @Column(length = 512) + private String Url; + + /** Outbound body as sent, JSON. Credentials are redacted before this is stored. */ + @Column(columnDefinition = "TEXT") + private String RequestBody; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MpesaResponse.java b/src/main/java/com/test/payment/models/audit/MpesaResponse.java new file mode 100644 index 0000000..b87bc59 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MpesaResponse.java @@ -0,0 +1,52 @@ +package com.test.payment.models.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * What M-Pesa answered a MpesaRequest with — including the failures, which is + * usually what an audit is actually needed for. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class MpesaResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request", nullable = false) + private MpesaRequest Request; + + /** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */ + private Integer HttpStatus; + + /** Response body verbatim, JSON. */ + @Column(columnDefinition = "TEXT") + private String ResponseBody; + + /** Exception summary when the call failed outright. */ + @Column(length = 512) + private String Error; + + /** Round-trip time, for spotting M-Pesa degrading before it starts erroring. */ + private Long DurationMs; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MtnCallbackResponse.java b/src/main/java/com/test/payment/models/audit/MtnCallbackResponse.java new file mode 100644 index 0000000..e8ce2b7 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MtnCallbackResponse.java @@ -0,0 +1,78 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * An asynchronous result callback from MTN MoMo, stored whole alongside the fields + * worth querying on, and tied back to the call that caused it. Unlike + * PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail + * that silently drops repeats is not one. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class MtnCallbackResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Null when the callback could not be matched to an outbound request. */ + @ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request") + private MtnRequest Request; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + @Column(length = 100) + private String ProviderReference; + + @Column(length = 30) + private String ResultCode; + + @Column(length = 255) + private String ResultDesc; + + @Column(length = 50) + private String ReceiptNumber; + + @Column(precision = 10, scale = 2) + private BigDecimal Amount; + + @Column(length = 15) + private String PhoneNumber; + + @Column(length = 20) + private String TransactionDate; + + /** The callback exactly as it arrived. */ + @Column(columnDefinition = "TEXT") + private String RawPayload; + + /** False when no matching request could be found — an orphan worth alerting on. */ + @Column(nullable = false) + private boolean Matched; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MtnRequest.java b/src/main/java/com/test/payment/models/audit/MtnRequest.java new file mode 100644 index 0000000..590a17e --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MtnRequest.java @@ -0,0 +1,68 @@ +package com.test.payment.models.audit; + +import com.test.payment.models.PaymentProviderType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * One outbound call to MTN MoMo, recorded verbatim: what we sent, where, and on whose + * behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path + * waits for this row. + */ +@Entity +@Getter +@Setter +@ToString +public class MtnRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + /** Market-qualified provider, e.g. MTN_UG. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentProviderType Provider; + + /** Which call this was: REQUEST_TO_PAY, STATUS_QUERY, TOKEN. */ + @Column(nullable = false, length = 40) + private String Operation; + + /** + * The payment attempt this call belongs to; null for calls with no payment + * (token fetches). A string, not the numeric id, so it can carry a NanoID. + */ + @Column(length = 36) + private String InitiationId; + + /** + * The provider's own reference once known, so a later callback can be tied back + * to the exact call that produced it. + */ + @Column(length = 100) + private String ProviderReference; + + @Column(length = 10) + private String HttpMethod; + + @Column(length = 512) + private String Url; + + /** Outbound body as sent, JSON. Credentials are redacted before this is stored. */ + @Column(columnDefinition = "TEXT") + private String RequestBody; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/models/audit/MtnResponse.java b/src/main/java/com/test/payment/models/audit/MtnResponse.java new file mode 100644 index 0000000..c990e93 --- /dev/null +++ b/src/main/java/com/test/payment/models/audit/MtnResponse.java @@ -0,0 +1,52 @@ +package com.test.payment.models.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +/** + * What MTN MoMo answered a MtnRequest with — including the failures, which is + * usually what an audit is actually needed for. + */ +@Entity +@Getter +@Setter +@ToString(exclude = "Request") +public class MtnResponse { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY) + @JoinColumn(name = "Request", nullable = false) + private MtnRequest Request; + + /** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */ + private Integer HttpStatus; + + /** Response body verbatim, JSON. */ + @Column(columnDefinition = "TEXT") + private String ResponseBody; + + /** Exception summary when the call failed outright. */ + @Column(length = 512) + private String Error; + + /** Round-trip time, for spotting MTN MoMo degrading before it starts erroring. */ + private Long DurationMs; + + @Column(nullable = false) + private Instant CreatedAt; +} diff --git a/src/main/java/com/test/payment/repository/AirtelPaymentCallbackRepository.java b/src/main/java/com/test/payment/repository/AirtelPaymentCallbackRepository.java new file mode 100644 index 0000000..9fe9565 --- /dev/null +++ b/src/main/java/com/test/payment/repository/AirtelPaymentCallbackRepository.java @@ -0,0 +1,20 @@ +package com.test.payment.repository; + +import com.test.payment.models.AirtelPaymentCallback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface AirtelPaymentCallbackRepository extends JpaRepository { + + @Query("SELECT c FROM AirtelPaymentCallback c WHERE c.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); +} diff --git a/src/main/java/com/test/payment/repository/AirtelPaymentResponseRepository.java b/src/main/java/com/test/payment/repository/AirtelPaymentResponseRepository.java new file mode 100644 index 0000000..5ec7161 --- /dev/null +++ b/src/main/java/com/test/payment/repository/AirtelPaymentResponseRepository.java @@ -0,0 +1,23 @@ +package com.test.payment.repository; + +import com.test.payment.models.AirtelPaymentResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface AirtelPaymentResponseRepository extends JpaRepository { + + @Query("SELECT r FROM AirtelPaymentResponse r WHERE r.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); + + @Query("SELECT r FROM AirtelPaymentResponse r WHERE r.ProviderReference = :providerReference") + Optional findByProviderReference(@Param("providerReference") String providerReference); +} diff --git a/src/main/java/com/test/payment/repository/MpesaPaymentCallbackRepository.java b/src/main/java/com/test/payment/repository/MpesaPaymentCallbackRepository.java new file mode 100644 index 0000000..fa115b1 --- /dev/null +++ b/src/main/java/com/test/payment/repository/MpesaPaymentCallbackRepository.java @@ -0,0 +1,20 @@ +package com.test.payment.repository; + +import com.test.payment.models.MpesaPaymentCallback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MpesaPaymentCallbackRepository extends JpaRepository { + + @Query("SELECT c FROM MpesaPaymentCallback c WHERE c.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); +} diff --git a/src/main/java/com/test/payment/repository/MpesaPaymentResponseRepository.java b/src/main/java/com/test/payment/repository/MpesaPaymentResponseRepository.java new file mode 100644 index 0000000..a741a0d --- /dev/null +++ b/src/main/java/com/test/payment/repository/MpesaPaymentResponseRepository.java @@ -0,0 +1,23 @@ +package com.test.payment.repository; + +import com.test.payment.models.MpesaPaymentResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MpesaPaymentResponseRepository extends JpaRepository { + + @Query("SELECT r FROM MpesaPaymentResponse r WHERE r.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); + + @Query("SELECT r FROM MpesaPaymentResponse r WHERE r.ProviderReference = :providerReference") + Optional findByProviderReference(@Param("providerReference") String providerReference); +} diff --git a/src/main/java/com/test/payment/repository/MtnPaymentCallbackRepository.java b/src/main/java/com/test/payment/repository/MtnPaymentCallbackRepository.java new file mode 100644 index 0000000..059f0cb --- /dev/null +++ b/src/main/java/com/test/payment/repository/MtnPaymentCallbackRepository.java @@ -0,0 +1,20 @@ +package com.test.payment.repository; + +import com.test.payment.models.MtnPaymentCallback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MtnPaymentCallbackRepository extends JpaRepository { + + @Query("SELECT c FROM MtnPaymentCallback c WHERE c.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); +} diff --git a/src/main/java/com/test/payment/repository/MtnPaymentResponseRepository.java b/src/main/java/com/test/payment/repository/MtnPaymentResponseRepository.java new file mode 100644 index 0000000..d88a822 --- /dev/null +++ b/src/main/java/com/test/payment/repository/MtnPaymentResponseRepository.java @@ -0,0 +1,23 @@ +package com.test.payment.repository; + +import com.test.payment.models.MtnPaymentResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MtnPaymentResponseRepository extends JpaRepository { + + @Query("SELECT r FROM MtnPaymentResponse r WHERE r.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); + + @Query("SELECT r FROM MtnPaymentResponse r WHERE r.ProviderReference = :providerReference") + Optional findByProviderReference(@Param("providerReference") String providerReference); +} diff --git a/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java b/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java deleted file mode 100644 index 86a0bc7..0000000 --- a/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.test.payment.repository; - -import com.test.payment.models.PaymentCallback; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; -import org.springframework.stereotype.Repository; -import reactor.core.publisher.Mono; - -@Repository -public interface PaymentCallbackRepository extends ReactiveCrudRepository { - - Mono findByInitiationId(Long initiationId); -} diff --git a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java index 1b63565..be4962e 100644 --- a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java +++ b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java @@ -1,39 +1,54 @@ package com.test.payment.repository; import com.test.payment.models.PaymentInitiation; -import org.springframework.data.r2dbc.repository.Query; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import com.test.payment.models.PaymentProviderType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ @Repository -public interface PaymentInitiationRepository extends ReactiveCrudRepository { +public interface PaymentInitiationRepository extends JpaRepository { - Flux findByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff); + @Query(""" + SELECT i FROM PaymentInitiation i + WHERE i.Status.Name = :statusName AND i.CreatedAt < :cutoff + """) + List findByStatusNameAndCreatedAtBefore(@Param("statusName") String statusName, + @Param("cutoff") LocalDateTime cutoff); /** * What this payer has already committed to on this provider inside the window. - * FAILED attempts do not count; PENDING ones do, so a burst of in-flight pushes + * Failed attempts do not count; pending ones do, so a burst of in-flight pushes * cannot overshoot the ceiling while their callbacks are outstanding. */ @Query(""" - SELECT COALESCE(SUM(amount), 0) FROM payment_initiations - WHERE provider = :provider AND phone_number = :phoneNumber - AND status <> 'FAILED' AND created_at >= :since + SELECT COALESCE(SUM(i.Amount), 0) FROM PaymentInitiation i + WHERE i.Provider = :provider AND i.PhoneNumber = :phoneNumber + AND i.Status.Name <> :failedStatus AND i.CreatedAt >= :since """) - Mono sumAmountInWindow(String provider, String phoneNumber, LocalDateTime since); + BigDecimal sumAmountInWindow(@Param("provider") PaymentProviderType provider, + @Param("phoneNumber") String phoneNumber, + @Param("since") LocalDateTime since, + @Param("failedStatus") String failedStatus); /** * The same running total, aggregated across every payer on the provider — * backs the merchant-wide exposure caps. */ @Query(""" - SELECT COALESCE(SUM(amount), 0) FROM payment_initiations - WHERE provider = :provider AND status <> 'FAILED' AND created_at >= :since + SELECT COALESCE(SUM(i.Amount), 0) FROM PaymentInitiation i + WHERE i.Provider = :provider AND i.Status.Name <> :failedStatus AND i.CreatedAt >= :since """) - Mono sumAmountInWindowForProvider(String provider, LocalDateTime since); + BigDecimal sumAmountInWindowForProvider(@Param("provider") PaymentProviderType provider, + @Param("since") LocalDateTime since, + @Param("failedStatus") String failedStatus); } diff --git a/src/main/java/com/test/payment/repository/PaymentResponseRepository.java b/src/main/java/com/test/payment/repository/PaymentResponseRepository.java deleted file mode 100644 index 019d9cb..0000000 --- a/src/main/java/com/test/payment/repository/PaymentResponseRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.test.payment.repository; - -import com.test.payment.models.PaymentResponse; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; -import org.springframework.stereotype.Repository; -import reactor.core.publisher.Mono; - -@Repository -public interface PaymentResponseRepository extends ReactiveCrudRepository { - - Mono findByInitiationId(Long initiationId); - - Mono findByProviderReference(String providerReference); -} diff --git a/src/main/java/com/test/payment/repository/ProviderLimitRepository.java b/src/main/java/com/test/payment/repository/ProviderLimitRepository.java index 01e9a17..84c4e51 100644 --- a/src/main/java/com/test/payment/repository/ProviderLimitRepository.java +++ b/src/main/java/com/test/payment/repository/ProviderLimitRepository.java @@ -1,19 +1,36 @@ package com.test.payment.repository; +import com.test.payment.models.PaymentProviderType; import com.test.payment.models.ProviderLimit; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; +import java.util.List; +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ @Repository -public interface ProviderLimitRepository extends ReactiveCrudRepository { +public interface ProviderLimitRepository extends JpaRepository { - Flux findByProviderAndActiveTrue(String provider); + @Query("SELECT l FROM ProviderLimit l WHERE l.Provider = :provider AND l.Active = TRUE") + List findByProviderAndActiveTrue(@Param("provider") PaymentProviderType provider); - Flux findByProviderOrderByPeriod(String provider); + @Query("SELECT l FROM ProviderLimit l WHERE l.Provider = :provider ORDER BY l.Period") + List findByProviderOrderByPeriod(@Param("provider") PaymentProviderType provider); - Flux findAllByOrderByProviderAscPeriodAsc(); + @Query("SELECT l FROM ProviderLimit l ORDER BY l.Provider ASC, l.Period ASC") + List findAllByOrderByProviderAscPeriodAsc(); - Mono findByProviderAndPeriodAndScope(String provider, String period, String scope); -} \ No newline at end of file + @Query(""" + SELECT l FROM ProviderLimit l + WHERE l.Provider = :provider AND l.Period = :period AND l.Scope = :scope + """) + Optional findByProviderAndPeriodAndScope(@Param("provider") PaymentProviderType provider, + @Param("period") String period, + @Param("scope") String scope); +} diff --git a/src/main/java/com/test/payment/repository/ProviderTokenRepository.java b/src/main/java/com/test/payment/repository/ProviderTokenRepository.java index d7d7a2a..5a1c30b 100644 --- a/src/main/java/com/test/payment/repository/ProviderTokenRepository.java +++ b/src/main/java/com/test/payment/repository/ProviderTokenRepository.java @@ -1,16 +1,35 @@ package com.test.payment.repository; +import com.test.payment.models.Operator; import com.test.payment.models.ProviderToken; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import reactor.core.publisher.Mono; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ @Repository -public interface ProviderTokenRepository extends ReactiveCrudRepository { +public interface ProviderTokenRepository extends JpaRepository { - Mono findFirstByProviderAndExpiresAtAfterOrderByIdDesc(String provider, LocalDateTime cutoff); + /** Newest usable token first; the caller takes the head. */ + @Query(""" + SELECT t FROM ProviderToken t + WHERE t.Provider = :provider AND t.ExpiresAt > :cutoff + ORDER BY t.Id DESC + """) + List findUsable(@Param("provider") Operator provider, + @Param("cutoff") LocalDateTime cutoff); - Mono deleteByProvider(String provider); + @Modifying + @Transactional + @Query("DELETE FROM ProviderToken t WHERE t.Provider = :provider") + void deleteByProvider(@Param("provider") Operator provider); } diff --git a/src/main/java/com/test/payment/repository/StatusRepository.java b/src/main/java/com/test/payment/repository/StatusRepository.java new file mode 100644 index 0000000..41c1fc0 --- /dev/null +++ b/src/main/java/com/test/payment/repository/StatusRepository.java @@ -0,0 +1,20 @@ +package com.test.payment.repository; + +import com.test.payment.models.Status; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface StatusRepository extends JpaRepository { + + @Query("SELECT s FROM Status s WHERE s.Name = :name") + Optional findByName(@Param("name") String name); +} diff --git a/src/main/java/com/test/payment/repository/TransactionRepository.java b/src/main/java/com/test/payment/repository/TransactionRepository.java index f941bce..8a1d491 100644 --- a/src/main/java/com/test/payment/repository/TransactionRepository.java +++ b/src/main/java/com/test/payment/repository/TransactionRepository.java @@ -1,17 +1,47 @@ package com.test.payment.repository; +import com.test.payment.models.PaymentProviderType; import com.test.payment.models.Transaction; -import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; +import java.util.List; +import java.util.Optional; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + * + *

The listing queries fetch-join every association. They have to: the + * associations are LAZY and serialization happens in the web layer, after the + * transaction has closed, so anything left as a proxy would blow up there. + */ @Repository -public interface TransactionRepository extends ReactiveCrudRepository { +public interface TransactionRepository extends JpaRepository { - Mono findByInitiationId(Long initiationId); + String GRAPH = """ + LEFT JOIN FETCH t.Initiation i + LEFT JOIN FETCH i.Status + LEFT JOIN FETCH t.Status + LEFT JOIN FETCH t.MpesaRequest + LEFT JOIN FETCH t.AirtelRequest + LEFT JOIN FETCH t.MtnRequest + LEFT JOIN FETCH t.MpesaCallback + LEFT JOIN FETCH t.AirtelCallback + LEFT JOIN FETCH t.MtnCallback + """; - Flux findByProvider(String provider); + @Query("SELECT t FROM Transaction t WHERE t.Initiation.Id = :initiationId") + Optional findByInitiationId(@Param("initiationId") Long initiationId); - Flux findByStatus(String status); + @Query("SELECT t FROM Transaction t " + GRAPH) + List findAllWithAssociations(); + + @Query("SELECT t FROM Transaction t " + GRAPH + " WHERE t.Provider = :provider") + List findByProvider(@Param("provider") PaymentProviderType provider); + + @Query("SELECT t FROM Transaction t WHERE t.Status.Name = :statusName") + List findByStatusName(@Param("statusName") String statusName); } diff --git a/src/main/java/com/test/payment/repository/audit/AirtelCallbackResponseRepository.java b/src/main/java/com/test/payment/repository/audit/AirtelCallbackResponseRepository.java new file mode 100644 index 0000000..a610488 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/AirtelCallbackResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.AirtelCallbackResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AirtelCallbackResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/repository/audit/AirtelRequestRepository.java b/src/main/java/com/test/payment/repository/audit/AirtelRequestRepository.java new file mode 100644 index 0000000..578a280 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/AirtelRequestRepository.java @@ -0,0 +1,25 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.AirtelRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface AirtelRequestRepository extends JpaRepository { + + /** Newest first; callers take the head. Ordering replaces a derived findFirst. */ + @Query("SELECT r FROM AirtelRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC") + List findByProviderReference(@Param("providerReference") String providerReference); + + /** Oldest first, so the head is the call that initiated the payment. */ + @Query("SELECT r FROM AirtelRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC") + List findByInitiationId(@Param("initiationId") String initiationId); +} diff --git a/src/main/java/com/test/payment/repository/audit/AirtelResponseRepository.java b/src/main/java/com/test/payment/repository/audit/AirtelResponseRepository.java new file mode 100644 index 0000000..617b2d8 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/AirtelResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.AirtelResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AirtelResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/repository/audit/MpesaCallbackResponseRepository.java b/src/main/java/com/test/payment/repository/audit/MpesaCallbackResponseRepository.java new file mode 100644 index 0000000..90c0f44 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MpesaCallbackResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MpesaCallbackResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MpesaCallbackResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/repository/audit/MpesaRequestRepository.java b/src/main/java/com/test/payment/repository/audit/MpesaRequestRepository.java new file mode 100644 index 0000000..b9d14ba --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MpesaRequestRepository.java @@ -0,0 +1,25 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MpesaRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MpesaRequestRepository extends JpaRepository { + + /** Newest first; callers take the head. Ordering replaces a derived findFirst. */ + @Query("SELECT r FROM MpesaRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC") + List findByProviderReference(@Param("providerReference") String providerReference); + + /** Oldest first, so the head is the call that initiated the payment. */ + @Query("SELECT r FROM MpesaRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC") + List findByInitiationId(@Param("initiationId") String initiationId); +} diff --git a/src/main/java/com/test/payment/repository/audit/MpesaResponseRepository.java b/src/main/java/com/test/payment/repository/audit/MpesaResponseRepository.java new file mode 100644 index 0000000..9178185 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MpesaResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MpesaResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MpesaResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/repository/audit/MtnCallbackResponseRepository.java b/src/main/java/com/test/payment/repository/audit/MtnCallbackResponseRepository.java new file mode 100644 index 0000000..0037778 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MtnCallbackResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MtnCallbackResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MtnCallbackResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/repository/audit/MtnRequestRepository.java b/src/main/java/com/test/payment/repository/audit/MtnRequestRepository.java new file mode 100644 index 0000000..af041f4 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MtnRequestRepository.java @@ -0,0 +1,25 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MtnRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Queries are explicit JPQL, not derived from method names: the entity's fields are + * PascalCase, so a derived name cannot resolve the property path. + */ +@Repository +public interface MtnRequestRepository extends JpaRepository { + + /** Newest first; callers take the head. Ordering replaces a derived findFirst. */ + @Query("SELECT r FROM MtnRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC") + List findByProviderReference(@Param("providerReference") String providerReference); + + /** Oldest first, so the head is the call that initiated the payment. */ + @Query("SELECT r FROM MtnRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC") + List findByInitiationId(@Param("initiationId") String initiationId); +} diff --git a/src/main/java/com/test/payment/repository/audit/MtnResponseRepository.java b/src/main/java/com/test/payment/repository/audit/MtnResponseRepository.java new file mode 100644 index 0000000..1ca23e0 --- /dev/null +++ b/src/main/java/com/test/payment/repository/audit/MtnResponseRepository.java @@ -0,0 +1,9 @@ +package com.test.payment.repository.audit; + +import com.test.payment.models.audit.MtnResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MtnResponseRepository extends JpaRepository { +} diff --git a/src/main/java/com/test/payment/service/AirtelService.java b/src/main/java/com/test/payment/service/AirtelService.java index 0f7053f..5df1e2c 100644 --- a/src/main/java/com/test/payment/service/AirtelService.java +++ b/src/main/java/com/test/payment/service/AirtelService.java @@ -9,12 +9,14 @@ import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.PaymentResultDto; import com.test.payment.dto.TransactionStatusDto; import com.test.payment.exceptions.ProviderBusyException; +import com.test.payment.models.Country; +import com.test.payment.models.Operator; import com.test.payment.models.PaymentProviderType; -import com.test.payment.models.PaymentResponse; -import com.test.payment.models.TransactionStatus; +import com.test.payment.models.Status; import com.test.payment.service.PaymentLifecycleService.CallbackData; import com.test.payment.service.PaymentLifecycleService.ProviderResponseData; import com.test.payment.service.PaymentLifecycleService.QueryOutcome; +import com.test.payment.service.PaymentLifecycleService.StoredResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; @@ -35,13 +37,16 @@ public class AirtelService implements PaymentProviderService { private final AirtelClient airtelClient; private final PaymentLifecycleService lifecycle; + private final ProviderMarkets markets; + private final ProviderCallAudit audit; + private final StatusCatalog statuses; private final PaymentLimitService limits; private final Environment environment; private final ObjectMapper objectMapper; @Override - public String provider() { - return PaymentProviderType.AIRTEL.name(); + public PaymentProviderType provider() { + return markets.resolve(Operator.AIRTEL); } @Override @@ -50,7 +55,13 @@ public class AirtelService implements PaymentProviderService { .then(lifecycle.saveInitiation(provider(), request)) .flatMap(initiation -> Mono.defer(() -> { String reference = "ATL" + UUID.randomUUID().toString().replace("-", ""); - return airtelClient.pay(buildRequest(request, reference)) + AirtelPaymentRequestDto payload = buildRequest(request, reference); + ProviderCallAudit.Handle call = audit.begin(provider(), "USSD_PUSH", String.valueOf(initiation.getId()), + "POST", baseUrl() + "/merchant/v1/payments/", payload); + audit.linkReference(call, reference); + return airtelClient.pay(payload) + .doOnNext(response -> audit.complete(call, 200, response, null)) + .doOnError(ex -> audit.complete(call, null, null, ex)) .map(response -> toResponseData(response, reference)); }) .flatMap(data -> lifecycle.persistResponse(initiation, data)) @@ -71,7 +82,11 @@ public class AirtelService implements PaymentProviderService { transaction.getAirtelMoneyId(), null, null, null, success); - return lifecycle.applyCallback(provider(), data, toJson(payload)); + String raw = toJson(payload); + audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit( + data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(), + data.amount(), data.phoneNumber(), data.transactionDate()), raw); + return lifecycle.applyCallback(provider(), data, raw); } @Override @@ -79,8 +94,12 @@ public class AirtelService implements PaymentProviderService { return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); } - private Mono queryProvider(PaymentResponse response) { - return airtelClient.status(response.getProviderReference()) + private Mono queryProvider(StoredResponse response) { + ProviderCallAudit.Handle call = audit.begin(provider(), "STATUS_QUERY", String.valueOf(response.initiationId()), + "GET", baseUrl() + "/standard/v1/payments/" + response.providerReference(), null); + return airtelClient.status(response.providerReference()) + .doOnNext(result -> audit.complete(call, 200, result, null)) + .doOnError(ex -> audit.complete(call, null, null, ex)) .map(result -> { AirtelResponseDto.Transaction tx = result.getData() != null ? result.getData().getTransaction() : null; String status = tx != null ? tx.getStatus() : null; @@ -91,17 +110,22 @@ public class AirtelService implements PaymentProviderService { tx != null ? tx.getAirtelMoneyId() : null); }) .onErrorResume(ProviderBusyException.class, - e -> Mono.just(QueryOutcome.pending("Airtel status query rate-limited — showing last known state"))); + e -> Mono.just(QueryOutcome.pending(statuses.pending(), "Airtel status query rate-limited — showing last known state"))); + } + + private String baseUrl() { + return environment.getProperty("airtel.base-url", ""); } private AirtelPaymentRequestDto buildRequest(PaymentRequest request, String reference) { - String country = environment.getProperty("airtel.country", "KE"); - String currency = environment.getProperty("airtel.currency", "KES"); + Country country = markets.resolve(Operator.AIRTEL).country(); + // the market's own currency unless airtel.currency deliberately overrides it + String currency = environment.getProperty("airtel.currency", country.currency()); return new AirtelPaymentRequestDto( request.getAccountReference(), - new AirtelPaymentRequestDto.Subscriber(country, currency, request.getPhoneNumber()), + new AirtelPaymentRequestDto.Subscriber(country.name(), currency, request.getPhoneNumber()), new AirtelPaymentRequestDto.Transaction( - String.valueOf(request.getAmount()), country, currency, reference)); + String.valueOf(request.getAmount()), country.name(), currency, reference)); } private ProviderResponseData toResponseData(AirtelResponseDto response, String reference) { @@ -119,14 +143,14 @@ public class AirtelService implements PaymentProviderService { accepted); } - private TransactionStatus mapStatus(String airtelStatus) { + private Status mapStatus(String airtelStatus) { if ("TS".equalsIgnoreCase(airtelStatus)) { - return TransactionStatus.SUCCESS; + return statuses.success(); } if ("TF".equalsIgnoreCase(airtelStatus)) { - return TransactionStatus.FAILED; + return statuses.failed(); } - return TransactionStatus.PENDING; // TIP or unknown — keep waiting + return statuses.pending(); // TIP or unknown — keep waiting } private String toJson(Object value) { diff --git a/src/main/java/com/test/payment/service/AirtelTokenService.java b/src/main/java/com/test/payment/service/AirtelTokenService.java index 85b04a7..99ef1cd 100644 --- a/src/main/java/com/test/payment/service/AirtelTokenService.java +++ b/src/main/java/com/test/payment/service/AirtelTokenService.java @@ -1,7 +1,7 @@ package com.test.payment.service; import com.test.payment.dto.OAuth2TokenResponse; -import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.Operator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; @@ -21,11 +21,11 @@ public class AirtelTokenService { private final Environment environment; public Mono getToken() { - return tokenCache.getToken(PaymentProviderType.AIRTEL.name(), this::fetchToken); + return tokenCache.getToken(Operator.AIRTEL, this::fetchToken); } public Mono evictToken() { - return tokenCache.evictToken(PaymentProviderType.AIRTEL.name()); + return tokenCache.evictToken(Operator.AIRTEL); } private Mono fetchToken() { diff --git a/src/main/java/com/test/payment/service/MpesaService.java b/src/main/java/com/test/payment/service/MpesaService.java index d334d7d..5a195b4 100644 --- a/src/main/java/com/test/payment/service/MpesaService.java +++ b/src/main/java/com/test/payment/service/MpesaService.java @@ -11,12 +11,13 @@ import com.test.payment.dto.StkQueryRequestDto; import com.test.payment.dto.TransactionStatusDto; import com.test.payment.exceptions.ProviderBusyException; import com.test.payment.exceptions.ProviderProcessingException; +import com.test.payment.models.Operator; import com.test.payment.models.PaymentProviderType; -import com.test.payment.models.PaymentResponse; -import com.test.payment.models.TransactionStatus; +import com.test.payment.models.Status; import com.test.payment.service.PaymentLifecycleService.CallbackData; import com.test.payment.service.PaymentLifecycleService.ProviderResponseData; import com.test.payment.service.PaymentLifecycleService.QueryOutcome; +import com.test.payment.service.PaymentLifecycleService.StoredResponse; import com.test.payment.utils.MpesaUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,20 +37,33 @@ public class MpesaService implements PaymentProviderService { private final MpesaClient mpesaClient; private final PaymentLifecycleService lifecycle; + private final ProviderMarkets markets; + private final ProviderCallAudit audit; + private final StatusCatalog statuses; private final PaymentLimitService limits; private final Environment environment; private final ObjectMapper objectMapper; @Override - public String provider() { - return PaymentProviderType.MPESA.name(); + public PaymentProviderType provider() { + return markets.resolve(Operator.MPESA); } @Override public Mono initiatePayment(PaymentRequest request) { return limits.enforce(provider(), request) .then(lifecycle.saveInitiation(provider(), request)) - .flatMap(initiation -> Mono.defer(() -> mpesaClient.stkPush(buildStkRequest(request))) + .flatMap(initiation -> Mono.defer(() -> { + MpesaRequestDto stk = buildStkRequest(request); + ProviderCallAudit.Handle call = audit.begin(provider(), "STK_PUSH", String.valueOf(initiation.getId()), + "POST", stkPushUrl(), stk); + return mpesaClient.stkPush(stk) + .doOnNext(response -> { + audit.complete(call, 200, response, null); + audit.linkReference(call, response.getCheckoutRequestId()); + }) + .doOnError(ex -> audit.complete(call, null, null, ex)); + }) .flatMap(response -> lifecycle.persistResponse(initiation, toResponseData(response))) .onErrorResume(ex -> lifecycle.markFailed(initiation, ex))); } @@ -71,7 +85,11 @@ public class MpesaService implements PaymentProviderService { asString(metadata.get("PhoneNumber")), asString(metadata.get("TransactionDate")), success); - return lifecycle.applyCallback(provider(), data, toJson(payload)); + String raw = toJson(payload); + audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit( + data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(), + data.amount(), data.phoneNumber(), data.transactionDate()), raw); + return lifecycle.applyCallback(provider(), data, raw); } @Override @@ -79,20 +97,31 @@ public class MpesaService implements PaymentProviderService { return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); } - private Mono queryProvider(PaymentResponse response) { + private Mono queryProvider(StoredResponse response) { String shortCode = environment.getProperty("mpesa.business-short-code"); String passkey = environment.getProperty("mpesa.pass-key"); MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey); StkQueryRequestDto query = new StkQueryRequestDto( - Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.getProviderReference()); + Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.providerReference()); + ProviderCallAudit.Handle call = audit.begin(provider(), "STK_QUERY", null, "POST", stkQueryUrl(), query); return mpesaClient.stkQuery(query) + .doOnNext(result -> audit.complete(call, 200, result, null)) + .doOnError(ex -> audit.complete(call, null, null, ex)) .map(result -> new QueryOutcome( mapQueryResult(result.getResultCode()), result.getResultCode(), result.getResultDesc(), null)) .onErrorResume(ProviderProcessingException.class, - e -> Mono.just(QueryOutcome.pending("Transaction is still being processed by M-Pesa"))) + e -> Mono.just(QueryOutcome.pending(statuses.pending(), "Transaction is still being processed by M-Pesa"))) .onErrorResume(ProviderBusyException.class, - e -> Mono.just(QueryOutcome.pending("M-Pesa status query rate-limited — showing last known state"))); + e -> Mono.just(QueryOutcome.pending(statuses.pending(), "M-Pesa status query rate-limited — showing last known state"))); + } + + private String stkPushUrl() { + return environment.getProperty("mpesa.base-url", "") + "/mpesa/stkpush/v1/processrequest"; + } + + private String stkQueryUrl() { + return environment.getProperty("mpesa.base-url", "") + "/mpesa/stkpushquery/v1/query"; } private MpesaRequestDto buildStkRequest(PaymentRequest request) { @@ -127,11 +156,11 @@ public class MpesaService implements PaymentProviderService { accepted); } - private TransactionStatus mapQueryResult(String resultCode) { + private Status mapQueryResult(String resultCode) { if (resultCode == null) { - return TransactionStatus.PENDING; + return statuses.pending(); } - return "0".equals(resultCode) ? TransactionStatus.SUCCESS : TransactionStatus.FAILED; + return "0".equals(resultCode) ? statuses.success() : statuses.failed(); } private Map extractMetadata(StkCallbackPayload.StkCallbackBody callback) { diff --git a/src/main/java/com/test/payment/service/MpesaTokenService.java b/src/main/java/com/test/payment/service/MpesaTokenService.java index 219dd80..a60280b 100644 --- a/src/main/java/com/test/payment/service/MpesaTokenService.java +++ b/src/main/java/com/test/payment/service/MpesaTokenService.java @@ -1,7 +1,7 @@ package com.test.payment.service; import com.test.payment.dto.MpesaTokenResponse; -import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.Operator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; @@ -19,11 +19,11 @@ public class MpesaTokenService { private final Environment environment; public Mono getToken() { - return tokenCache.getToken(PaymentProviderType.MPESA.name(), this::fetchToken); + return tokenCache.getToken(Operator.MPESA, this::fetchToken); } public Mono evictToken() { - return tokenCache.evictToken(PaymentProviderType.MPESA.name()); + return tokenCache.evictToken(Operator.MPESA); } private Mono fetchToken() { diff --git a/src/main/java/com/test/payment/service/MtnService.java b/src/main/java/com/test/payment/service/MtnService.java index 75a1c1c..117813b 100644 --- a/src/main/java/com/test/payment/service/MtnService.java +++ b/src/main/java/com/test/payment/service/MtnService.java @@ -8,12 +8,13 @@ import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.PaymentResultDto; import com.test.payment.dto.TransactionStatusDto; import com.test.payment.exceptions.ProviderBusyException; +import com.test.payment.models.Operator; import com.test.payment.models.PaymentProviderType; -import com.test.payment.models.PaymentResponse; -import com.test.payment.models.TransactionStatus; +import com.test.payment.models.Status; import com.test.payment.service.PaymentLifecycleService.CallbackData; import com.test.payment.service.PaymentLifecycleService.ProviderResponseData; import com.test.payment.service.PaymentLifecycleService.QueryOutcome; +import com.test.payment.service.PaymentLifecycleService.StoredResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; @@ -36,13 +37,16 @@ public class MtnService implements PaymentProviderService { private final MtnClient mtnClient; private final PaymentLifecycleService lifecycle; + private final ProviderMarkets markets; + private final ProviderCallAudit audit; + private final StatusCatalog statuses; private final PaymentLimitService limits; private final Environment environment; private final ObjectMapper objectMapper; @Override - public String provider() { - return PaymentProviderType.MTN.name(); + public PaymentProviderType provider() { + return markets.resolve(Operator.MTN); } @Override @@ -51,8 +55,14 @@ public class MtnService implements PaymentProviderService { .then(lifecycle.saveInitiation(provider(), request)) .flatMap(initiation -> Mono.defer(() -> { String reference = UUID.randomUUID().toString(); + MtnPayRequestDto payload = buildRequest(request, reference); + ProviderCallAudit.Handle call = audit.begin(provider(), "REQUEST_TO_PAY", + String.valueOf(initiation.getId()), "POST", baseUrl() + "/collection/v1_0/requesttopay", payload); + audit.linkReference(call, reference); // 202 Accepted, empty body — the reference is all we get back - return mtnClient.requestToPay(reference, buildRequest(request, reference)) + return mtnClient.requestToPay(reference, payload) + .doOnError(ex -> audit.complete(call, null, null, ex)) + .doOnSuccess(ignored -> audit.complete(call, 202, "<202 Accepted, empty body>", null)) .thenReturn(new ProviderResponseData( reference, null, "202", "Accepted", "Request to pay accepted", true)); }) @@ -76,7 +86,11 @@ public class MtnService implements PaymentProviderService { payload.getPayer() != null ? payload.getPayer().getPartyId() : null, null, success); - return lifecycle.applyCallback(provider(), data, toJson(payload)); + String raw = toJson(payload); + audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit( + data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(), + data.amount(), data.phoneNumber(), data.transactionDate()), raw); + return lifecycle.applyCallback(provider(), data, raw); } @Override @@ -84,15 +98,23 @@ public class MtnService implements PaymentProviderService { return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); } - private Mono queryProvider(PaymentResponse response) { - return mtnClient.status(response.getProviderReference()) + private Mono queryProvider(StoredResponse response) { + ProviderCallAudit.Handle call = audit.begin(provider(), "STATUS_QUERY", String.valueOf(response.initiationId()), "GET", + baseUrl() + "/collection/v1_0/requesttopay/" + response.providerReference(), null); + return mtnClient.status(response.providerReference()) + .doOnNext(result -> audit.complete(call, 200, result, null)) + .doOnError(ex -> audit.complete(call, null, null, ex)) .map(result -> new QueryOutcome( mapStatus(result.getStatus()), result.getStatus(), "SUCCESSFUL".equalsIgnoreCase(result.getStatus()) ? "Payment successful" : result.reasonText(), result.getFinancialTransactionId())) .onErrorResume(ProviderBusyException.class, - e -> Mono.just(QueryOutcome.pending("MTN status query rate-limited — showing last known state"))); + e -> Mono.just(QueryOutcome.pending(statuses.pending(), "MTN status query rate-limited — showing last known state"))); + } + + private String baseUrl() { + return environment.getProperty("mtn.base-url", ""); } private MtnPayRequestDto buildRequest(PaymentRequest request, String reference) { @@ -105,14 +127,14 @@ public class MtnService implements PaymentProviderService { request.getAccountReference()); } - private TransactionStatus mapStatus(String mtnStatus) { + private Status mapStatus(String mtnStatus) { if ("SUCCESSFUL".equalsIgnoreCase(mtnStatus)) { - return TransactionStatus.SUCCESS; + return statuses.success(); } if ("FAILED".equalsIgnoreCase(mtnStatus)) { - return TransactionStatus.FAILED; + return statuses.failed(); } - return TransactionStatus.PENDING; + return statuses.pending(); } private String toJson(Object value) { diff --git a/src/main/java/com/test/payment/service/MtnTokenService.java b/src/main/java/com/test/payment/service/MtnTokenService.java index 515bace..19e3b9c 100644 --- a/src/main/java/com/test/payment/service/MtnTokenService.java +++ b/src/main/java/com/test/payment/service/MtnTokenService.java @@ -1,7 +1,7 @@ package com.test.payment.service; import com.test.payment.dto.OAuth2TokenResponse; -import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.Operator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; @@ -19,11 +19,11 @@ public class MtnTokenService { private final Environment environment; public Mono getToken() { - return tokenCache.getToken(PaymentProviderType.MTN.name(), this::fetchToken); + return tokenCache.getToken(Operator.MTN, this::fetchToken); } public Mono evictToken() { - return tokenCache.evictToken(PaymentProviderType.MTN.name()); + return tokenCache.evictToken(Operator.MTN); } private Mono fetchToken() { diff --git a/src/main/java/com/test/payment/service/PaymentLifecycleService.java b/src/main/java/com/test/payment/service/PaymentLifecycleService.java index c380371..fe82002 100644 --- a/src/main/java/com/test/payment/service/PaymentLifecycleService.java +++ b/src/main/java/com/test/payment/service/PaymentLifecycleService.java @@ -4,151 +4,105 @@ import com.test.payment.dto.CallbackAckDto; import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.PaymentResultDto; import com.test.payment.dto.TransactionStatusDto; -import com.test.payment.models.PaymentCallback; import com.test.payment.models.PaymentInitiation; -import com.test.payment.models.PaymentResponse; +import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.Status; import com.test.payment.models.Transaction; -import com.test.payment.models.TransactionStatus; -import com.test.payment.repository.PaymentCallbackRepository; -import com.test.payment.repository.PaymentInitiationRepository; -import com.test.payment.repository.PaymentResponseRepository; -import com.test.payment.repository.TransactionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.math.BigDecimal; import java.time.LocalDateTime; -import java.util.Optional; +import java.util.concurrent.Callable; import java.util.function.Function; /** * Provider-agnostic persistence and lifecycle: initiation → response → callback / * status query → consolidated transaction. Provider services delegate here and only * contribute the provider-specific HTTP calls and payload parsing. + * + *

Persistence is JPA, which blocks, so this class is a thin reactive facade: it + * hands each unit of work to {@link PaymentLifecycleStore} on a bounded-elastic + * thread and never touches a repository on the event loop. Provider HTTP calls stay + * outside the transaction — the store is only entered before and after them. */ @Service @RequiredArgsConstructor @Slf4j public class PaymentLifecycleService { - private final PaymentInitiationRepository initiationRepository; - private final PaymentResponseRepository responseRepository; - private final PaymentCallbackRepository callbackRepository; - private final TransactionRepository transactionRepository; + private final PaymentLifecycleStore store; /** The provider's answer to an initiation. */ public record ProviderResponseData(String providerReference, String secondaryReference, String responseCode, String responseDescription, String customerMessage, boolean accepted) { } + /** + * A stored provider response, read back provider-neutrally. The response tables + * are per operator and share no supertype, so the lifecycle passes this view + * around rather than an entity. + */ + public record StoredResponse(Long initiationId, PaymentProviderType provider, String providerReference, + String secondaryReference, String responseCode, String responseDescription, + String customerMessage) { + } + /** A parsed provider callback. */ public record CallbackData(String providerReference, String resultCode, String resultDesc, String receiptNumber, BigDecimal amount, String phoneNumber, String transactionDate, boolean success) { } - /** The outcome of a live status query. */ - public record QueryOutcome(TransactionStatus newStatus, String resultCode, String resultDesc, String receiptNumber) { - public static QueryOutcome pending(String note) { - return new QueryOutcome(TransactionStatus.PENDING, null, note, null); + /** The outcome of a live status query. {@code newStatus} is a row from STATUSES. */ + public record QueryOutcome(Status newStatus, String resultCode, String resultDesc, String receiptNumber) { + /** Still open — the provider had no verdict yet, so the payment stays as it is. */ + public static QueryOutcome pending(Status pending, String note) { + return new QueryOutcome(pending, null, note, null); } } - public Mono saveInitiation(String provider, PaymentRequest request) { - return initiationRepository.save(PaymentInitiation.builder() - .provider(provider) - .phoneNumber(request.getPhoneNumber()) - .amount(BigDecimal.valueOf(request.getAmount())) - .accountReference(request.getAccountReference()) - .transactionDesc(request.getTransactionDesc()) - .status(TransactionStatus.PENDING.name()) - .createdAt(LocalDateTime.now()) - .build()); + public Mono saveInitiation(PaymentProviderType provider, PaymentRequest request) { + return blocking(() -> store.saveInitiation(provider, request)); } public Mono persistResponse(PaymentInitiation initiation, ProviderResponseData data) { - String newStatus = data.accepted() ? TransactionStatus.PENDING.name() : TransactionStatus.FAILED.name(); - - PaymentResponse entity = PaymentResponse.builder() - .initiationId(initiation.getId()) - .provider(initiation.getProvider()) - .providerReference(data.providerReference()) - .secondaryReference(data.secondaryReference()) - .responseCode(data.responseCode()) - .responseDescription(data.responseDescription()) - .customerMessage(data.customerMessage()) - .createdAt(LocalDateTime.now()) - .build(); - - // one response per initiation — an existing row wins, a concurrent insert falls back to it - return responseRepository.findByInitiationId(initiation.getId()) - .switchIfEmpty(Mono.defer(() -> responseRepository.save(entity) - .onErrorResume(DuplicateKeyException.class, - e -> responseRepository.findByInitiationId(initiation.getId())))) - .flatMap(saved -> updateStatus(initiation, newStatus) - .flatMap(updated -> data.accepted() - ? Mono.just(updated) - : recordTransaction(updated, saved, null, - data.responseDescription(), null, null, "REJECTION") - .thenReturn(updated)) - .map(updated -> PaymentResultDto.builder() - .initiationId(updated.getId()) - .provider(updated.getProvider()) - .status(updated.getStatus()) - .providerReference(saved.getProviderReference()) - .secondaryReference(saved.getSecondaryReference()) - .responseCode(saved.getResponseCode()) - .responseDescription(saved.getResponseDescription()) - .customerMessage(saved.getCustomerMessage()) - .build())); + return blocking(() -> store.persistResponse(initiation.getId(), data)); } public Mono markFailed(PaymentInitiation initiation, Throwable ex) { log.error("[{}] payment failed for initiation {}: {}", initiation.getProvider(), initiation.getId(), ex.toString()); String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); - return updateStatus(initiation, TransactionStatus.FAILED.name()) - .flatMap(updated -> recordTransaction(updated, null, null, truncate(reason), null, null, "ERROR")) - .then(Mono.error(ex)); + return blocking(() -> { + store.markFailed(initiation.getId(), reason); + return true; + }).then(Mono.error(ex)); } - public Mono applyCallback(String provider, CallbackData data, String rawPayload) { + public Mono applyCallback(PaymentProviderType provider, CallbackData data, String rawPayload) { if (data.providerReference() == null) { log.warn("[{}] callback without a provider reference ignored", provider); return Mono.just(CallbackAckDto.accepted("Ignored: no reference")); } - return responseRepository.findByProviderReference(data.providerReference()) - .filter(response -> provider.equals(response.getProvider())) - .flatMap(response -> callbackRepository.findByInitiationId(response.getInitiationId()) - .map(existing -> { - log.info("[{}] duplicate callback for {} ignored", provider, data.providerReference()); - return CallbackAckDto.accepted("Duplicate callback ignored"); - }) - .switchIfEmpty(Mono.defer(() -> saveCallback(response, data, rawPayload)))) - .switchIfEmpty(Mono.fromSupplier(() -> { - log.warn("[{}] callback for unknown reference {}", provider, data.providerReference()); - return CallbackAckDto.accepted("Unknown reference"); - })); + return blocking(() -> store.applyCallback(provider, data, rawPayload)); } /** * Returns the transaction state from the database; if it is still PENDING, runs the - * provider's live status query (querier) and applies the outcome. + * provider's live status query (querier) and applies the outcome. The query itself + * runs between two transactions, never inside one. */ - public Mono checkStatus(String provider, String providerReference, - Function> querier) { - return responseRepository.findByProviderReference(providerReference) - .filter(response -> provider.equals(response.getProvider())) - .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, - "No " + provider + " transaction found for reference " + providerReference))) - .flatMap(response -> initiationRepository.findById(response.getInitiationId()) - .flatMap(initiation -> TransactionStatus.PENDING.name().equals(initiation.getStatus()) - ? querier.apply(response).flatMap(outcome -> applyQueryOutcome(initiation, response, outcome)) - : buildStatusDto(initiation, response))); + public Mono checkStatus(PaymentProviderType provider, String providerReference, + Function> querier) { + return blocking(() -> store.loadForStatusCheck(provider, providerReference)) + .flatMap(context -> context.pending() + ? querier.apply(context.response()) + .flatMap(outcome -> blocking(() -> + store.applyQueryOutcome(context.response().initiationId(), outcome))) + : Mono.just(context.currentState())); } /** @@ -156,172 +110,27 @@ public class PaymentLifecycleService { */ public Mono reconcile(PaymentInitiation initiation, Function> statusChecker) { - return responseRepository.findByInitiationId(initiation.getId()) - .flatMap(response -> { - if (response.getProviderReference() == null) { - return failTerminal(initiation, "No provider reference on response").thenReturn(true); - } - return statusChecker.apply(response.getProviderReference()).thenReturn(true); - }) - .switchIfEmpty(Mono.defer(() -> { - log.warn("[{}] initiation {} never received a provider response — marking FAILED", - initiation.getProvider(), initiation.getId()); - return failTerminal(initiation, "No provider response received").thenReturn(false); - })) + return blocking(() -> store.beginReconcile(initiation.getId())) + .flatMap(reference -> reference + .map(ref -> statusChecker.apply(ref).then()) + .orElseGet(Mono::empty)) .then(); } - public Flux listTransactions(String provider) { - return provider == null ? transactionRepository.findAll() : transactionRepository.findByProvider(provider); + public Flux listTransactions(PaymentProviderType provider) { + return blocking(() -> store.listTransactions(provider)).flatMapMany(Flux::fromIterable); } - private Mono failTerminal(PaymentInitiation initiation, String reason) { - return updateStatus(initiation, TransactionStatus.FAILED.name()) - .flatMap(updated -> recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION")); - } - - private Mono saveCallback(PaymentResponse response, CallbackData data, String rawPayload) { - PaymentCallback entity = PaymentCallback.builder() - .initiationId(response.getInitiationId()) - .provider(response.getProvider()) - .providerReference(data.providerReference()) - .resultCode(data.resultCode()) - .resultDesc(data.resultDesc()) - .receiptNumber(data.receiptNumber()) - .amount(data.amount()) - .phoneNumber(data.phoneNumber()) - .transactionDate(data.transactionDate()) - .rawPayload(rawPayload) - .createdAt(LocalDateTime.now()) - .build(); - - String newStatus = data.success() ? TransactionStatus.SUCCESS.name() : TransactionStatus.FAILED.name(); - return callbackRepository.save(entity) - .onErrorResume(DuplicateKeyException.class, - e -> callbackRepository.findByInitiationId(response.getInitiationId())) - .flatMap(saved -> initiationRepository.findById(response.getInitiationId()) - .flatMap(initiation -> updateStatus(initiation, newStatus)) - .flatMap(updated -> recordTransaction(updated, response, data.resultCode(), - data.resultDesc(), data.receiptNumber(), data.transactionDate(), "CALLBACK"))) - .doOnNext(tx -> log.info("[{}] callback processed for initiation {} — status {}", - tx.getProvider(), tx.getInitiationId(), tx.getStatus())) - .thenReturn(CallbackAckDto.accepted("Callback processed")); - } - - private Mono applyQueryOutcome(PaymentInitiation initiation, PaymentResponse response, - QueryOutcome outcome) { - if (outcome.newStatus() == TransactionStatus.PENDING) { - return buildStatusDto(initiation, response) - .map(dto -> { - if (outcome.resultDesc() != null) { - dto.setResultDesc(outcome.resultDesc()); - } - return dto; - }); - } - return updateStatus(initiation, outcome.newStatus().name()) - .flatMap(updated -> recordTransaction(updated, response, outcome.resultCode(), - outcome.resultDesc(), outcome.receiptNumber(), null, "QUERY") - .then(buildStatusDto(updated, response))) - .map(dto -> { - dto.setResultCode(outcome.resultCode()); - dto.setResultDesc(outcome.resultDesc()); - if (outcome.receiptNumber() != null) { - dto.setReceiptNumber(outcome.receiptNumber()); - } - return dto; - }); - } - - private Mono buildStatusDto(PaymentInitiation initiation, PaymentResponse response) { - // result details come from the callback when we have one, otherwise from the - // consolidated transaction row (e.g. when a status query resolved the payment) - return callbackRepository.findByInitiationId(initiation.getId()) - .map(Optional::of) - .defaultIfEmpty(Optional.empty()) - .zipWith(transactionRepository.findByInitiationId(initiation.getId()) - .map(Optional::of) - .defaultIfEmpty(Optional.empty())) - .map(tuple -> { - Optional cb = tuple.getT1(); - Optional tx = tuple.getT2(); - return TransactionStatusDto.builder() - .initiationId(initiation.getId()) - .provider(initiation.getProvider()) - .providerReference(response.getProviderReference()) - .secondaryReference(response.getSecondaryReference()) - .status(initiation.getStatus()) - .phoneNumber(initiation.getPhoneNumber()) - .amount(initiation.getAmount()) - .accountReference(initiation.getAccountReference()) - .resultCode(cb.map(PaymentCallback::getResultCode) - .or(() -> tx.map(Transaction::getResultCode)).orElse(null)) - .resultDesc(cb.map(PaymentCallback::getResultDesc) - .or(() -> tx.map(Transaction::getResultDesc)).orElse(null)) - .receiptNumber(cb.map(PaymentCallback::getReceiptNumber) - .or(() -> tx.map(Transaction::getReceiptNumber)).orElse(null)) - .createdAt(initiation.getCreatedAt()) - .updatedAt(initiation.getUpdatedAt()) - .build(); - }); - } - - private Mono updateStatus(PaymentInitiation initiation, String status) { - initiation.setStatus(status); - initiation.setUpdatedAt(LocalDateTime.now()); - return initiationRepository.save(initiation); + /** PENDING initiations older than the cutoff, for the reconciliation job. */ + public Flux findPendingOlderThan(LocalDateTime cutoff) { + return blocking(() -> store.findPendingOlderThan(cutoff)).flatMapMany(Flux::fromIterable); } /** - * Upserts the consolidated TRANSACTIONS row for an initiation that reached a - * terminal state. Keyed by initiation_id (UNIQUE) so it can never duplicate; - * a later, richer resolution (e.g. a callback after a query) updates the row. + * Runs one blocking unit of work off the event loop. boundedElastic is the + * scheduler Reactor sizes for exactly this — JDBC calls parked on I/O. */ - private Mono recordTransaction(PaymentInitiation initiation, PaymentResponse response, - String resultCode, String resultDesc, String receiptNumber, - String transactionDate, String resolvedBy) { - return transactionRepository.findByInitiationId(initiation.getId()) - .flatMap(existing -> { - existing.setStatus(initiation.getStatus()); - if (resultCode != null) { - existing.setResultCode(resultCode); - } - if (resultDesc != null) { - existing.setResultDesc(resultDesc); - } - if (receiptNumber != null) { - existing.setReceiptNumber(receiptNumber); - } - if (transactionDate != null) { - existing.setTransactionDate(transactionDate); - } - existing.setResolvedBy(resolvedBy); - existing.setUpdatedAt(LocalDateTime.now()); - return transactionRepository.save(existing); - }) - .switchIfEmpty(Mono.defer(() -> transactionRepository.save(Transaction.builder() - .initiationId(initiation.getId()) - .provider(initiation.getProvider()) - .providerReference(response != null ? response.getProviderReference() : null) - .secondaryReference(response != null ? response.getSecondaryReference() : null) - .phoneNumber(initiation.getPhoneNumber()) - .amount(initiation.getAmount()) - .accountReference(initiation.getAccountReference()) - .status(initiation.getStatus()) - .resultCode(resultCode) - .resultDesc(resultDesc) - .receiptNumber(receiptNumber) - .transactionDate(transactionDate) - .resolvedBy(resolvedBy) - .createdAt(LocalDateTime.now()) - .build()) - .onErrorResume(DuplicateKeyException.class, - e -> transactionRepository.findByInitiationId(initiation.getId())))) - .doOnNext(tx -> log.info("[{}] transaction {} recorded for initiation {} — status {} (via {})", - initiation.getProvider(), tx.getId(), initiation.getId(), tx.getStatus(), resolvedBy)); - } - - private String truncate(String value) { - return value == null || value.length() <= 255 ? value : value.substring(0, 255); + private Mono blocking(Callable work) { + return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()); } } diff --git a/src/main/java/com/test/payment/service/PaymentLifecycleStore.java b/src/main/java/com/test/payment/service/PaymentLifecycleStore.java new file mode 100644 index 0000000..91378ea --- /dev/null +++ b/src/main/java/com/test/payment/service/PaymentLifecycleStore.java @@ -0,0 +1,508 @@ +package com.test.payment.service; + +import com.test.payment.dto.CallbackAckDto; +import com.test.payment.dto.PaymentRequest; +import com.test.payment.dto.PaymentResultDto; +import com.test.payment.dto.TransactionStatusDto; +import com.test.payment.models.AirtelPaymentCallback; +import com.test.payment.models.AirtelPaymentResponse; +import com.test.payment.models.MpesaPaymentCallback; +import com.test.payment.models.MpesaPaymentResponse; +import com.test.payment.models.MtnPaymentCallback; +import com.test.payment.models.MtnPaymentResponse; +import com.test.payment.models.PaymentInitiation; +import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.Status; +import com.test.payment.models.Transaction; +import com.test.payment.repository.AirtelPaymentCallbackRepository; +import com.test.payment.repository.AirtelPaymentResponseRepository; +import com.test.payment.repository.MpesaPaymentCallbackRepository; +import com.test.payment.repository.MpesaPaymentResponseRepository; +import com.test.payment.repository.MtnPaymentCallbackRepository; +import com.test.payment.repository.MtnPaymentResponseRepository; +import com.test.payment.repository.PaymentInitiationRepository; +import com.test.payment.repository.audit.AirtelRequestRepository; +import com.test.payment.repository.audit.MpesaRequestRepository; +import com.test.payment.repository.audit.MtnRequestRepository; +import com.test.payment.repository.TransactionRepository; +import com.test.payment.service.PaymentLifecycleService.CallbackData; +import com.test.payment.service.PaymentLifecycleService.ProviderResponseData; +import com.test.payment.service.PaymentLifecycleService.QueryOutcome; +import com.test.payment.service.PaymentLifecycleService.StoredResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * The blocking, transactional half of {@link PaymentLifecycleService}. Every method + * here is one unit of work against JPA; the facade is what puts them on a + * bounded-elastic thread so the WebFlux event loop is never blocked. + * + *

It lives in its own bean deliberately: {@code @Transactional} is applied by a + * Spring AOP proxy, which self-invocation from the facade would bypass — the same + * trap the Resilience4j annotations have in the client classes. + * + *

Responses and callbacks live in per-operator tables that share no supertype, so + * this class dispatches on {@link com.test.payment.models.Operator} and hands the + * rest of the lifecycle a provider-neutral {@link StoredResponse} view. Note the + * provider equality checks: one table holds every market for that operator, so a + * MPESA_TZ reference must not resolve against a MPESA_KE payment. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PaymentLifecycleStore { + + private final PaymentInitiationRepository initiationRepository; + private final TransactionRepository transactionRepository; + private final StatusCatalog statuses; + + private final MpesaPaymentResponseRepository mpesaResponses; + private final MpesaPaymentCallbackRepository mpesaCallbacks; + private final AirtelPaymentResponseRepository airtelResponses; + private final AirtelPaymentCallbackRepository airtelCallbacks; + private final MtnPaymentResponseRepository mtnResponses; + private final MtnPaymentCallbackRepository mtnCallbacks; + + // the outbound-call audit tables, so a transaction can point at the call it made + private final MpesaRequestRepository mpesaRequests; + private final AirtelRequestRepository airtelRequests; + private final MtnRequestRepository mtnRequests; + + /** + * What a status check needs before deciding whether to hit the provider: the + * stored response, whether the payment is still open, and the state to serve + * when it is not. + */ + public record StatusCheckContext(StoredResponse response, boolean pending, TransactionStatusDto currentState) { + } + + /** The callback fields the status DTO reads back. */ + public record StoredCallback(String resultCode, String resultDesc, String receiptNumber) { + } + + /** + * Opens a payment attempt: the initiation and its consolidated transaction row are + * created together. The transaction exists from the request onwards rather than + * appearing only at resolution, so it can be linked to the provider's response and + * callback as each arrives, and a payment is never invisible in TRANSACTIONS. + */ + @Transactional + public PaymentInitiation saveInitiation(PaymentProviderType provider, PaymentRequest request) { + PaymentInitiation initiation = initiationRepository.save(PaymentInitiation.builder() + .Provider(provider) + .PhoneNumber(request.getPhoneNumber()) + .Amount(BigDecimal.valueOf(request.getAmount())) + .AccountReference(request.getAccountReference()) + .TransactionDesc(request.getTransactionDesc()) + .Status(statuses.pending()) + .CreatedAt(LocalDateTime.now()) + .build()); + + transactionRepository.save(Transaction.builder() + .Initiation(initiation) + .Provider(provider) + .PhoneNumber(initiation.getPhoneNumber()) + .Amount(initiation.getAmount()) + .AccountReference(initiation.getAccountReference()) + .Status(initiation.getStatus()) + .CreatedAt(LocalDateTime.now()) + .build()); + + return initiation; + } + + @Transactional + public PaymentResultDto persistResponse(Long initiationId, ProviderResponseData data) { + PaymentInitiation initiation = requireInitiation(initiationId); + Status newStatus = data.accepted() ? statuses.pending() : statuses.failed(); + + // one response per initiation — an existing row wins, a concurrent insert falls back to it + StoredResponse saved = findResponseByInitiation(initiation.getProvider(), initiationId) + .orElseGet(() -> insertResponse(initiation, data)); + + PaymentInitiation updated = updateStatus(initiation, newStatus); + // linked either way: a rejection resolves the payment, an acceptance just + // records which response row belongs to it + recordTransaction(updated, saved, null, data.responseDescription(), null, null, + data.accepted() ? null : "REJECTION"); + + return PaymentResultDto.builder() + .initiationId(updated.getId()) + .provider(updated.getProvider()) + .status(updated.statusName()) + .providerReference(saved.providerReference()) + .secondaryReference(saved.secondaryReference()) + .responseCode(saved.responseCode()) + .responseDescription(saved.responseDescription()) + .customerMessage(saved.customerMessage()) + .build(); + } + + @Transactional + public void markFailed(Long initiationId, String reason) { + PaymentInitiation updated = updateStatus(requireInitiation(initiationId), statuses.failed()); + recordTransaction(updated, null, null, truncate(reason), null, null, "ERROR"); + } + + @Transactional + public CallbackAckDto applyCallback(PaymentProviderType provider, CallbackData data, String rawPayload) { + Optional match = findResponseByReference(provider, data.providerReference()); + + if (match.isEmpty()) { + log.warn("[{}] callback for unknown reference {}", provider, data.providerReference()); + return CallbackAckDto.accepted("Unknown reference"); + } + StoredResponse response = match.get(); + + if (findCallbackByInitiation(provider, response.initiationId()).isPresent()) { + log.info("[{}] duplicate callback for {} ignored", provider, data.providerReference()); + return CallbackAckDto.accepted("Duplicate callback ignored"); + } + return saveCallback(response, data, rawPayload); + } + + /** + * Loads what a status check needs. Never calls the provider — the HTTP query is + * the facade's job, so no transaction is held open across the network. + */ + @Transactional(readOnly = true) + public StatusCheckContext loadForStatusCheck(PaymentProviderType provider, String providerReference) { + StoredResponse response = findResponseByReference(provider, providerReference) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "No " + provider + " transaction found for reference " + providerReference)); + + PaymentInitiation initiation = requireInitiation(response.initiationId()); + boolean pending = statuses.isPending(initiation.statusName()); + return new StatusCheckContext(response, pending, pending ? null : buildStatusDto(initiation, response)); + } + + /** Applies the outcome of a live provider status query. */ + @Transactional + public TransactionStatusDto applyQueryOutcome(Long initiationId, QueryOutcome outcome) { + PaymentInitiation initiation = requireInitiation(initiationId); + StoredResponse response = findResponseByInitiation(initiation.getProvider(), initiationId).orElse(null); + + if (statuses.isPending(outcome.newStatus())) { + TransactionStatusDto dto = buildStatusDto(initiation, response); + if (outcome.resultDesc() != null) { + dto.setResultDesc(outcome.resultDesc()); + } + return dto; + } + + PaymentInitiation updated = updateStatus(initiation, outcome.newStatus()); + recordTransaction(updated, response, outcome.resultCode(), outcome.resultDesc(), + outcome.receiptNumber(), null, "QUERY"); + + TransactionStatusDto dto = buildStatusDto(updated, response); + dto.setResultCode(outcome.resultCode()); + dto.setResultDesc(outcome.resultDesc()); + if (outcome.receiptNumber() != null) { + dto.setReceiptNumber(outcome.receiptNumber()); + } + return dto; + } + + /** + * First half of a reconciliation: resolves the provider reference to re-query, + * or terminally fails the initiation when there is nothing to query with. + * + * @return the provider reference, or empty when the initiation was failed outright + */ + @Transactional + public Optional beginReconcile(Long initiationId) { + PaymentInitiation initiation = requireInitiation(initiationId); + Optional response = findResponseByInitiation(initiation.getProvider(), initiationId); + + if (response.isEmpty()) { + log.warn("[{}] initiation {} never received a provider response — marking FAILED", + initiation.getProvider(), initiationId); + failTerminal(initiation, "No provider response received"); + return Optional.empty(); + } + if (response.get().providerReference() == null) { + failTerminal(initiation, "No provider reference on response"); + return Optional.empty(); + } + return Optional.of(response.get().providerReference()); + } + + @Transactional(readOnly = true) + public List listTransactions(PaymentProviderType provider) { + return provider == null + ? transactionRepository.findAllWithAssociations() + : transactionRepository.findByProvider(provider); + } + + @Transactional(readOnly = true) + public List findPendingOlderThan(LocalDateTime cutoff) { + return initiationRepository.findByStatusNameAndCreatedAtBefore(statuses.pending().getName(), cutoff); + } + + // --- per-operator dispatch ------------------------------------------------- + + private StoredResponse insertResponse(PaymentInitiation initiation, ProviderResponseData data) { + PaymentProviderType provider = initiation.getProvider(); + try { + return switch (provider.operator()) { + case MPESA -> view(mpesaResponses.saveAndFlush(MpesaPaymentResponse.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()) + .SecondaryReference(data.secondaryReference()) + .ResponseCode(data.responseCode()) + .ResponseDescription(data.responseDescription()) + .CustomerMessage(data.customerMessage()) + .CreatedAt(LocalDateTime.now()).build())); + case AIRTEL -> view(airtelResponses.saveAndFlush(AirtelPaymentResponse.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()) + .SecondaryReference(data.secondaryReference()) + .ResponseCode(data.responseCode()) + .ResponseDescription(data.responseDescription()) + .CustomerMessage(data.customerMessage()) + .CreatedAt(LocalDateTime.now()).build())); + case MTN -> view(mtnResponses.saveAndFlush(MtnPaymentResponse.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()) + .SecondaryReference(data.secondaryReference()) + .ResponseCode(data.responseCode()) + .ResponseDescription(data.responseDescription()) + .CustomerMessage(data.customerMessage()) + .CreatedAt(LocalDateTime.now()).build())); + }; + } catch (DataIntegrityViolationException ex) { + return findResponseByInitiation(provider, initiation.getId()).orElseThrow(() -> ex); + } + } + + private Optional findResponseByInitiation(PaymentProviderType provider, Long initiationId) { + return switch (provider.operator()) { + case MPESA -> mpesaResponses.findByInitiationId(initiationId).map(this::view); + case AIRTEL -> airtelResponses.findByInitiationId(initiationId).map(this::view); + case MTN -> mtnResponses.findByInitiationId(initiationId).map(this::view); + }; + } + + /** The provider filter matters: one table holds every market for that operator. */ + private Optional findResponseByReference(PaymentProviderType provider, String providerReference) { + if (providerReference == null) { + return Optional.empty(); + } + Optional found = switch (provider.operator()) { + case MPESA -> mpesaResponses.findByProviderReference(providerReference).map(this::view); + case AIRTEL -> airtelResponses.findByProviderReference(providerReference).map(this::view); + case MTN -> mtnResponses.findByProviderReference(providerReference).map(this::view); + }; + return found.filter(response -> provider == response.provider()); + } + + private Optional findCallbackByInitiation(PaymentProviderType provider, Long initiationId) { + return switch (provider.operator()) { + case MPESA -> mpesaCallbacks.findByInitiationId(initiationId) + .map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber())); + case AIRTEL -> airtelCallbacks.findByInitiationId(initiationId) + .map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber())); + case MTN -> mtnCallbacks.findByInitiationId(initiationId) + .map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber())); + }; + } + + private CallbackAckDto saveCallback(StoredResponse response, CallbackData data, String rawPayload) { + PaymentProviderType provider = response.provider(); + PaymentInitiation initiation = requireInitiation(response.initiationId()); + LocalDateTime now = LocalDateTime.now(); + + try { + switch (provider.operator()) { + case MPESA -> mpesaCallbacks.saveAndFlush(MpesaPaymentCallback.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()).ResultCode(data.resultCode()) + .ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber()) + .Amount(data.amount()).PhoneNumber(data.phoneNumber()) + .TransactionDate(data.transactionDate()).RawPayload(rawPayload) + .CreatedAt(now).build()); + case AIRTEL -> airtelCallbacks.saveAndFlush(AirtelPaymentCallback.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()).ResultCode(data.resultCode()) + .ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber()) + .Amount(data.amount()).PhoneNumber(data.phoneNumber()) + .TransactionDate(data.transactionDate()).RawPayload(rawPayload) + .CreatedAt(now).build()); + case MTN -> mtnCallbacks.saveAndFlush(MtnPaymentCallback.builder() + .Initiation(initiation).Provider(provider) + .ProviderReference(data.providerReference()).ResultCode(data.resultCode()) + .ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber()) + .Amount(data.amount()).PhoneNumber(data.phoneNumber()) + .TransactionDate(data.transactionDate()).RawPayload(rawPayload) + .CreatedAt(now).build()); + } + } catch (DataIntegrityViolationException ex) { + log.info("[{}] concurrent duplicate callback for {} ignored", provider, data.providerReference()); + return CallbackAckDto.accepted("Duplicate callback ignored"); + } + + // A receipt means the money actually moved, which is Paid rather than a bare Success. + Status newStatus = data.success() + ? (data.receiptNumber() != null ? statuses.paid() : statuses.success()) + : statuses.failed(); + + PaymentInitiation updated = updateStatus(initiation, newStatus); + Transaction tx = recordTransaction(updated, response, data.resultCode(), data.resultDesc(), + data.receiptNumber(), data.transactionDate(), "CALLBACK"); + log.info("[{}] callback processed for initiation {} — status {}", + tx.getProvider(), tx.initiationId(), tx.statusName()); + return CallbackAckDto.accepted("Callback processed"); + } + + private StoredResponse view(MpesaPaymentResponse r) { + return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(), + r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage()); + } + + private StoredResponse view(AirtelPaymentResponse r) { + return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(), + r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage()); + } + + private StoredResponse view(MtnPaymentResponse r) { + return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(), + r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage()); + } + + // --- shared lifecycle ------------------------------------------------------ + + private Transaction failTerminal(PaymentInitiation initiation, String reason) { + PaymentInitiation updated = updateStatus(initiation, statuses.failed()); + return recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION"); + } + + private TransactionStatusDto buildStatusDto(PaymentInitiation initiation, StoredResponse response) { + // result details come from the callback when we have one, otherwise from the + // consolidated transaction row (e.g. when a status query resolved the payment) + Optional cb = findCallbackByInitiation(initiation.getProvider(), initiation.getId()); + Optional tx = transactionRepository.findByInitiationId(initiation.getId()); + + return TransactionStatusDto.builder() + .initiationId(initiation.getId()) + .provider(initiation.getProvider()) + .providerReference(response == null ? null : response.providerReference()) + .secondaryReference(response == null ? null : response.secondaryReference()) + .status(initiation.statusName()) + .phoneNumber(initiation.getPhoneNumber()) + .amount(initiation.getAmount()) + .accountReference(initiation.getAccountReference()) + .resultCode(cb.map(StoredCallback::resultCode) + .or(() -> tx.map(Transaction::getResultCode)).orElse(null)) + .resultDesc(cb.map(StoredCallback::resultDesc) + .or(() -> tx.map(Transaction::getResultDesc)).orElse(null)) + .receiptNumber(cb.map(StoredCallback::receiptNumber) + .or(() -> tx.map(Transaction::getReceiptNumber)).orElse(null)) + .createdAt(initiation.getCreatedAt()) + .updatedAt(initiation.getUpdatedAt()) + .build(); + } + + private PaymentInitiation updateStatus(PaymentInitiation initiation, Status status) { + initiation.setStatus(status); + initiation.setUpdatedAt(LocalDateTime.now()); + return initiationRepository.save(initiation); + } + + /** + * Upserts the consolidated TRANSACTIONS row for an initiation that reached a + * terminal state. Keyed by initiation (UNIQUE) so it can never duplicate; + * a later, richer resolution (e.g. a callback after a query) updates the row. + */ + private Transaction recordTransaction(PaymentInitiation initiation, StoredResponse response, + String resultCode, String resultDesc, String receiptNumber, + String transactionDate, String resolvedBy) { + Transaction tx = transactionRepository.findByInitiationId(initiation.getId()) + .orElseGet(() -> Transaction.builder() + .Initiation(initiation) + .Provider(initiation.getProvider()) + .PhoneNumber(initiation.getPhoneNumber()) + .Amount(initiation.getAmount()) + .AccountReference(initiation.getAccountReference()) + .CreatedAt(LocalDateTime.now()) + .build()); + + if (response != null) { + tx.setProviderReference(response.providerReference()); + tx.setSecondaryReference(response.secondaryReference()); + } + tx.setStatus(initiation.getStatus()); + if (resultCode != null) { + tx.setResultCode(resultCode); + } + if (resultDesc != null) { + tx.setResultDesc(resultDesc); + } + if (receiptNumber != null) { + tx.setReceiptNumber(receiptNumber); + } + if (transactionDate != null) { + tx.setTransactionDate(transactionDate); + } + if (resolvedBy != null) { + tx.setResolvedBy(resolvedBy); + } + if (tx.getId() != null) { + tx.setUpdatedAt(LocalDateTime.now()); + } + + attachRequest(tx, initiation.getProvider(), initiation.getId()); + attachCallback(tx, initiation.getProvider(), initiation.getId()); + + Transaction saved = transactionRepository.save(tx); + log.info("[{}] transaction {} recorded for initiation {} — status {} (via {})", + initiation.getProvider(), saved.getId(), initiation.getId(), saved.statusName(), resolvedBy); + return saved; + } + + /** + * Links the call that started this payment — the earliest audit request for the + * initiation, since later rows are status queries. Best effort: audit rows are + * written asynchronously, so on the rare occasion the row has not landed yet the + * link is simply picked up by the next update. + */ + private void attachRequest(Transaction tx, PaymentProviderType provider, Long initiationId) { + String reference = String.valueOf(initiationId); + switch (provider.operator()) { + case MPESA -> earliest(mpesaRequests.findByInitiationId(reference)).ifPresent(tx::setMpesaRequest); + case AIRTEL -> earliest(airtelRequests.findByInitiationId(reference)).ifPresent(tx::setAirtelRequest); + case MTN -> earliest(mtnRequests.findByInitiationId(reference)).ifPresent(tx::setMtnRequest); + } + } + + private Optional earliest(List rows) { + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + /** Same for the callback, which only exists once the operator has reported back. */ + private void attachCallback(Transaction tx, PaymentProviderType provider, Long initiationId) { + switch (provider.operator()) { + case MPESA -> mpesaCallbacks.findByInitiationId(initiationId).ifPresent(tx::setMpesaCallback); + case AIRTEL -> airtelCallbacks.findByInitiationId(initiationId).ifPresent(tx::setAirtelCallback); + case MTN -> mtnCallbacks.findByInitiationId(initiationId).ifPresent(tx::setMtnCallback); + } + } + + private PaymentInitiation requireInitiation(Long initiationId) { + return initiationRepository.findById(initiationId) + .orElseThrow(() -> new IllegalStateException("Initiation " + initiationId + " no longer exists")); + } + + private String truncate(String value) { + return value == null || value.length() <= 255 ? value : value.substring(0, 255); + } +} diff --git a/src/main/java/com/test/payment/service/PaymentLimitService.java b/src/main/java/com/test/payment/service/PaymentLimitService.java index 302da11..64f6da0 100644 --- a/src/main/java/com/test/payment/service/PaymentLimitService.java +++ b/src/main/java/com/test/payment/service/PaymentLimitService.java @@ -2,166 +2,51 @@ package com.test.payment.service; import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.ProviderLimitDto; -import com.test.payment.exceptions.PaymentLimitExceededException; -import com.test.payment.models.LimitPeriod; -import com.test.payment.models.LimitScope; import com.test.payment.models.PaymentProviderType; import com.test.payment.models.ProviderLimit; -import com.test.payment.repository.PaymentInitiationRepository; -import com.test.payment.repository.ProviderLimitRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Arrays; +import java.util.concurrent.Callable; /** * Enforces the ceilings configured in PROVIDER_LIMITS before a payment is * persisted or dispatched. Cumulative periods (DAILY, MONTHLY) are scoped to the * paying MSISDN on that provider, which is how mobile-money operators express them. + * + *

Reactive facade over {@link PaymentLimitStore}, which holds the blocking JPA work. */ @Service @RequiredArgsConstructor @Slf4j public class PaymentLimitService { - private final ProviderLimitRepository limitRepository; - private final PaymentInitiationRepository initiationRepository; + private final PaymentLimitStore store; /** * Completes empty when the request is within every active limit; signals * PaymentLimitExceededException on the first breach. */ - public Mono enforce(String provider, PaymentRequest request) { - BigDecimal amount = BigDecimal.valueOf(request.getAmount()); - LocalDateTime now = LocalDateTime.now(); - - return limitRepository.findByProviderAndActiveTrue(provider) - .concatMap(limit -> check(limit, provider, request.getPhoneNumber(), amount, now)) - .then(); + public Mono enforce(PaymentProviderType provider, PaymentRequest request) { + return blocking(() -> { + store.enforce(provider, request); + return true; + }).then(); } - private Mono check(ProviderLimit limit, String provider, String phoneNumber, - BigDecimal amount, LocalDateTime now) { - LimitPeriod period = parsePeriod(limit); - if (period == null) { - return Mono.empty(); - } - - if (!period.isCumulative()) { - return amount.compareTo(limit.getMaxAmount()) > 0 - ? Mono.error(breach(limit, provider, amount, BigDecimal.ZERO)) - : Mono.empty(); - } - - LocalDateTime since = period.windowStart(now); - Mono alreadySpentInWindow = scopeOf(limit) == LimitScope.MERCHANT - ? initiationRepository.sumAmountInWindowForProvider(provider, since) - : initiationRepository.sumAmountInWindow(provider, phoneNumber, since); - - return alreadySpentInWindow - .defaultIfEmpty(BigDecimal.ZERO) - .flatMap(alreadySpent -> alreadySpent.add(amount).compareTo(limit.getMaxAmount()) > 0 - ? Mono.error(breach(limit, provider, amount, alreadySpent)) - : Mono.empty()); + public Flux list(PaymentProviderType provider) { + return blocking(() -> store.list(provider)).flatMapMany(Flux::fromIterable); } - private PaymentLimitExceededException breach(ProviderLimit limit, String provider, - BigDecimal amount, BigDecimal alreadySpent) { - String currency = limit.getCurrency() == null ? "" : limit.getCurrency() + " "; - String window = LimitPeriod.valueOf(limit.getPeriod()).isCumulative() - ? limit.getPeriod() + " " + scopeOf(limit) - : limit.getPeriod(); - String detail = alreadySpent.signum() > 0 - ? " (%s%s already used in this period)".formatted(currency, alreadySpent.toPlainString()) - : ""; - String message = "%s %s limit exceeded: requested %s%s against a maximum of %s%s%s".formatted( - provider, - window, - currency, amount.toPlainString(), - currency, limit.getMaxAmount().toPlainString(), - detail); - log.info("Rejecting payment — {}", message); - return new PaymentLimitExceededException(message); - } - - public Flux list(String provider) { - return provider == null - ? limitRepository.findAllByOrderByProviderAscPeriodAsc() - : limitRepository.findByProviderOrderByPeriod(provider); - } - - /** - * Creates or updates the ceiling for one provider/period/scope triple. - * Scope defaults to PER_PAYER when the caller omits it. - */ public Mono upsert(ProviderLimitDto dto) { - String provider = dto.getProvider().toUpperCase(); - String period = dto.getPeriod().toUpperCase(); - String scope = dto.getScope() == null ? LimitScope.PER_PAYER.name() : dto.getScope().toUpperCase(); - validateNames(provider, period, scope); - - LocalDateTime now = LocalDateTime.now(); - return limitRepository.findByProviderAndPeriodAndScope(provider, period, scope) - .switchIfEmpty(Mono.fromSupplier(() -> ProviderLimit.builder() - .provider(provider) - .period(period) - .scope(scope) - .createdAt(now) - .build())) - .flatMap(limit -> { - limit.setMaxAmount(dto.getMaxAmount()); - limit.setCurrency(dto.getCurrency() != null ? dto.getCurrency() : limit.getCurrency()); - limit.setActive(dto.getActive() == null ? Boolean.TRUE : dto.getActive()); - limit.setUpdatedAt(now); - return limitRepository.save(limit); - }) - .doOnNext(limit -> log.info("Provider limit set: {} {} = {}", - limit.getProvider(), limit.getPeriod(), limit.getMaxAmount())); + return blocking(() -> store.upsert(dto)); } - private void validateNames(String provider, String period, String scope) { - try { - PaymentProviderType.valueOf(provider); - LimitPeriod.valueOf(period); - LimitScope.valueOf(scope); - } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "provider must be one of %s, period one of %s and scope one of %s".formatted( - Arrays.toString(PaymentProviderType.values()), - Arrays.toString(LimitPeriod.values()), - Arrays.toString(LimitScope.values()))); - } + private Mono blocking(Callable work) { + return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()); } - - /** - * An unset or unrecognised scope falls back to PER_PAYER — the tighter of the two, - * so a bad row can never silently widen a ceiling. - */ - private LimitScope scopeOf(ProviderLimit limit) { - try { - return LimitScope.valueOf(limit.getScope()); - } catch (IllegalArgumentException | NullPointerException ex) { - return LimitScope.PER_PAYER; - } - } - - /** - * A row whose period no longer maps to a LimitPeriod constant is skipped rather - * than failing every payment for that provider. - */ - private LimitPeriod parsePeriod(ProviderLimit limit) { - try { - return LimitPeriod.valueOf(limit.getPeriod()); - } catch (IllegalArgumentException | NullPointerException ex) { - log.warn("Ignoring provider limit {} with unknown period '{}'", limit.getId(), limit.getPeriod()); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/main/java/com/test/payment/service/PaymentLimitStore.java b/src/main/java/com/test/payment/service/PaymentLimitStore.java new file mode 100644 index 0000000..83d60c9 --- /dev/null +++ b/src/main/java/com/test/payment/service/PaymentLimitStore.java @@ -0,0 +1,181 @@ +package com.test.payment.service; + +import com.test.payment.dto.PaymentRequest; +import com.test.payment.dto.ProviderLimitDto; +import com.test.payment.exceptions.PaymentLimitExceededException; +import com.test.payment.models.LimitPeriod; +import com.test.payment.models.LimitScope; +import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.ProviderLimit; +import com.test.payment.repository.PaymentInitiationRepository; +import com.test.payment.repository.ProviderLimitRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * The blocking, transactional half of {@link PaymentLimitService} — see + * {@link PaymentLifecycleStore} for why the split exists. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PaymentLimitStore { + + private final ProviderLimitRepository limitRepository; + private final PaymentInitiationRepository initiationRepository; + private final StatusCatalog statuses; + + /** + * Returns normally when the request is within every active limit; throws + * PaymentLimitExceededException on the first breach. + */ + @Transactional(readOnly = true) + public void enforce(PaymentProviderType provider, PaymentRequest request) { + BigDecimal amount = BigDecimal.valueOf(request.getAmount()); + LocalDateTime now = LocalDateTime.now(); + + for (ProviderLimit limit : limitRepository.findByProviderAndActiveTrue(provider)) { + check(limit, provider, request.getPhoneNumber(), amount, now); + } + } + + @Transactional(readOnly = true) + public List list(PaymentProviderType provider) { + return provider == null + ? limitRepository.findAllByOrderByProviderAscPeriodAsc() + : limitRepository.findByProviderOrderByPeriod(provider); + } + + /** + * Creates or updates the ceiling for one provider/period/scope triple. + * Scope defaults to PER_PAYER when the caller omits it. + */ + @Transactional + public ProviderLimit upsert(ProviderLimitDto dto) { + PaymentProviderType provider = parseProvider(dto.getProvider()); + String period = dto.getPeriod().toUpperCase(); + String scope = dto.getScope() == null ? LimitScope.PER_PAYER.name() : dto.getScope().toUpperCase(); + validateNames(period, scope); + + LocalDateTime now = LocalDateTime.now(); + ProviderLimit limit = limitRepository.findByProviderAndPeriodAndScope(provider, period, scope) + .orElseGet(() -> ProviderLimit.builder() + .Provider(provider) + .Period(period) + .Scope(scope) + .CreatedAt(now) + .build()); + + limit.setMaxAmount(dto.getMaxAmount()); + limit.setCurrency(dto.getCurrency() != null ? dto.getCurrency() : limit.getCurrency()); + limit.setActive(dto.getActive() == null ? Boolean.TRUE : dto.getActive()); + limit.setUpdatedAt(now); + + ProviderLimit saved = limitRepository.save(limit); + log.info("Provider limit set: {} {} = {}", saved.getProvider(), saved.getPeriod(), saved.getMaxAmount()); + return saved; + } + + private void check(ProviderLimit limit, PaymentProviderType provider, String phoneNumber, + BigDecimal amount, LocalDateTime now) { + LimitPeriod period = parsePeriod(limit); + if (period == null) { + return; + } + + if (!period.isCumulative()) { + if (amount.compareTo(limit.getMaxAmount()) > 0) { + throw breach(limit, provider, amount, BigDecimal.ZERO); + } + return; + } + + LocalDateTime since = period.windowStart(now); + BigDecimal alreadySpent = scopeOf(limit) == LimitScope.MERCHANT + ? initiationRepository.sumAmountInWindowForProvider(provider, since, statuses.failedName()) + : initiationRepository.sumAmountInWindow(provider, phoneNumber, since, statuses.failedName()); + if (alreadySpent == null) { + alreadySpent = BigDecimal.ZERO; + } + + if (alreadySpent.add(amount).compareTo(limit.getMaxAmount()) > 0) { + throw breach(limit, provider, amount, alreadySpent); + } + } + + private PaymentLimitExceededException breach(ProviderLimit limit, PaymentProviderType provider, + BigDecimal amount, BigDecimal alreadySpent) { + String currency = limit.getCurrency() == null ? "" : limit.getCurrency() + " "; + String window = LimitPeriod.valueOf(limit.getPeriod()).isCumulative() + ? limit.getPeriod() + " " + scopeOf(limit) + : limit.getPeriod(); + String detail = alreadySpent.signum() > 0 + ? " (%s%s already used in this period)".formatted(currency, alreadySpent.toPlainString()) + : ""; + String message = "%s %s limit exceeded: requested %s%s against a maximum of %s%s%s".formatted( + provider, + window, + currency, amount.toPlainString(), + currency, limit.getMaxAmount().toPlainString(), + detail); + log.info("Rejecting payment — {}", message); + return new PaymentLimitExceededException(message); + } + + /** Parses the market-qualified provider from a request body, e.g. "airtel_ke". */ + private PaymentProviderType parseProvider(String value) { + try { + return PaymentProviderType.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException | NullPointerException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "provider must be one of " + Arrays.toString(PaymentProviderType.values())); + } + } + + private void validateNames(String period, String scope) { + try { + LimitPeriod.valueOf(period); + LimitScope.valueOf(scope); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "provider must be one of %s, period one of %s and scope one of %s".formatted( + Arrays.toString(PaymentProviderType.values()), + Arrays.toString(LimitPeriod.values()), + Arrays.toString(LimitScope.values()))); + } + } + + /** + * An unset or unrecognised scope falls back to PER_PAYER — the tighter of the two, + * so a bad row can never silently widen a ceiling. + */ + private LimitScope scopeOf(ProviderLimit limit) { + try { + return LimitScope.valueOf(limit.getScope()); + } catch (IllegalArgumentException | NullPointerException ex) { + return LimitScope.PER_PAYER; + } + } + + /** + * A row whose period no longer maps to a LimitPeriod constant is skipped rather + * than failing every payment for that provider. + */ + private LimitPeriod parsePeriod(ProviderLimit limit) { + try { + return LimitPeriod.valueOf(limit.getPeriod()); + } catch (IllegalArgumentException | NullPointerException ex) { + log.warn("Ignoring provider limit {} with unknown period '{}'", limit.getId(), limit.getPeriod()); + return null; + } + } +} diff --git a/src/main/java/com/test/payment/service/PaymentProviderService.java b/src/main/java/com/test/payment/service/PaymentProviderService.java index cf342d8..11c1972 100644 --- a/src/main/java/com/test/payment/service/PaymentProviderService.java +++ b/src/main/java/com/test/payment/service/PaymentProviderService.java @@ -3,6 +3,7 @@ package com.test.payment.service; import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.PaymentResultDto; import com.test.payment.dto.TransactionStatusDto; +import com.test.payment.models.PaymentProviderType; import reactor.core.publisher.Mono; /** @@ -12,7 +13,8 @@ import reactor.core.publisher.Mono; */ public interface PaymentProviderService { - String provider(); + /** The market-qualified provider this service collects for, e.g. AIRTEL_KE. */ + PaymentProviderType provider(); Mono initiatePayment(PaymentRequest request); diff --git a/src/main/java/com/test/payment/service/ProviderCallAudit.java b/src/main/java/com/test/payment/service/ProviderCallAudit.java new file mode 100644 index 0000000..74e1c07 --- /dev/null +++ b/src/main/java/com/test/payment/service/ProviderCallAudit.java @@ -0,0 +1,350 @@ +package com.test.payment.service; + +import com.test.payment.models.Operator; +import com.test.payment.models.PaymentProviderType; +import com.test.payment.models.audit.AirtelCallbackResponse; +import com.test.payment.models.audit.AirtelRequest; +import com.test.payment.models.audit.AirtelResponse; +import com.test.payment.models.audit.MpesaCallbackResponse; +import com.test.payment.models.audit.MpesaRequest; +import com.test.payment.models.audit.MpesaResponse; +import com.test.payment.models.audit.MtnCallbackResponse; +import com.test.payment.models.audit.MtnRequest; +import com.test.payment.models.audit.MtnResponse; +import com.test.payment.repository.audit.AirtelCallbackResponseRepository; +import com.test.payment.repository.audit.AirtelRequestRepository; +import com.test.payment.repository.audit.AirtelResponseRepository; +import com.test.payment.repository.audit.MpesaCallbackResponseRepository; +import com.test.payment.repository.audit.MpesaRequestRepository; +import com.test.payment.repository.audit.MpesaResponseRepository; +import com.test.payment.repository.audit.MtnCallbackResponseRepository; +import com.test.payment.repository.audit.MtnRequestRepository; +import com.test.payment.repository.audit.MtnResponseRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.regex.Pattern; + +/** + * Records every call we make to an operator — request body, response body, timing, + * failures — plus the callbacks they send back, into that operator's own tables + * (mpesa_requests / mpesa_responses / mpesa_callback_responses, and the Airtel and + * MTN equivalents). The nine entities are standalone with no shared supertype, so + * this class dispatches on {@link Operator} rather than on a common base class. + * + *

Everything here runs on the audit executor, never on the caller's thread: a + * payment is never delayed by, and never fails because of, its audit trail. Ordering + * within one call is preserved by chaining onto the request's own future rather than + * by blocking on it — a {@link Handle} carries only the eventual row id. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ProviderCallAudit { + + /** Anything that looks like a credential is masked before it reaches the table. */ + private static final Pattern SECRETS = Pattern.compile( + "(?i)(\"(?:password|passkey|pass_key|api[_-]?key|apikey|secret|client_secret|authorization|access_token)\"\\s*:\\s*)\"[^\"]*\""); + + private final ExecutorService auditExecutor; + private final ObjectMapper objectMapper; + + private final MpesaRequestRepository mpesaRequests; + private final MpesaResponseRepository mpesaResponses; + private final MpesaCallbackResponseRepository mpesaCallbacks; + private final AirtelRequestRepository airtelRequests; + private final AirtelResponseRepository airtelResponses; + private final AirtelCallbackResponseRepository airtelCallbacks; + private final MtnRequestRepository mtnRequests; + private final MtnResponseRepository mtnResponses; + private final MtnCallbackResponseRepository mtnCallbacks; + + /** + * A call in flight. Holds the future id of the persisted request row so the + * response can be attached to it without anyone blocking. The id is null when + * the request row could not be written. + */ + public record Handle(PaymentProviderType provider, CompletableFuture requestId, long startedAt) { + public static Handle none() { + return new Handle(null, CompletableFuture.completedFuture(null), 0L); + } + } + + /** The parsed fields of a callback worth querying on, alongside the raw payload. */ + public record CallbackAudit(String providerReference, String resultCode, String resultDesc, String receiptNumber, + BigDecimal amount, String phoneNumber, String transactionDate) { + } + + /** Records an outbound call. Returns immediately; the row is written in the background. */ + public Handle begin(PaymentProviderType provider, String operation, String initiationId, + String httpMethod, String url, Object requestBody) { + long startedAt = System.nanoTime(); + String body = redact(toJson(requestBody)); + String trimmedUrl = truncate(url, 512); + + CompletableFuture requestId = CompletableFuture.supplyAsync( + () -> saveRequest(provider, operation, initiationId, httpMethod, trimmedUrl, body), + auditExecutor) + .exceptionally(ex -> { + log.warn("[{}] could not record {} request audit: {}", provider, operation, ex.toString()); + return null; + }); + + return new Handle(provider, requestId, startedAt); + } + + /** Records the operator's answer against the call opened by {@link #begin}. */ + public void complete(Handle handle, Integer httpStatus, Object responseBody, Throwable error) { + if (handle == null || handle.provider() == null) { + return; + } + long durationMs = (System.nanoTime() - handle.startedAt()) / 1_000_000; + String body = redact(toJson(responseBody)); + String failure = error == null ? null : truncate(error.toString(), 512); + + handle.requestId().thenAcceptAsync(requestId -> { + if (requestId == null) { + return; + } + saveResponse(handle.provider(), requestId, httpStatus, body, failure, durationMs); + }, auditExecutor).exceptionally(ex -> { + log.warn("[{}] could not record response audit: {}", handle.provider(), ex.toString()); + return null; + }); + } + + /** + * Stamps the operator's reference onto the call that produced it, which is what + * later lets an inbound callback be matched back to its originating request. + */ + public void linkReference(Handle handle, String providerReference) { + if (handle == null || handle.provider() == null || providerReference == null) { + return; + } + handle.requestId().thenAcceptAsync(requestId -> { + if (requestId == null) { + return; + } + stampReference(handle.provider(), requestId, providerReference); + }, auditExecutor).exceptionally(ex -> { + log.warn("[{}] could not link provider reference {}: {}", + handle.provider(), providerReference, ex.toString()); + return null; + }); + } + + /** Records an inbound callback, matched to its originating request where possible. */ + public void recordCallback(PaymentProviderType provider, CallbackAudit data, String rawPayload) { + String payload = redact(rawPayload); + CompletableFuture.runAsync(() -> saveCallback(provider, data, payload), auditExecutor) + .exceptionally(ex -> { + log.warn("[{}] could not record callback audit: {}", provider, ex.toString()); + return null; + }); + } + + // --- per-operator dispatch ------------------------------------------------- + // Explicit rather than polymorphic: the nine tables are independent by design, and + // a new operator should not compile until all three of its tables are wired up. + + private Long saveRequest(PaymentProviderType provider, String operation, String initiationId, + String httpMethod, String url, String body) { + return switch (provider.operator()) { + case MPESA -> { + MpesaRequest entity = new MpesaRequest(); + entity.setProvider(provider); + entity.setOperation(operation); + entity.setInitiationId(initiationId); + entity.setHttpMethod(httpMethod); + entity.setUrl(url); + entity.setRequestBody(body); + entity.setCreatedAt(Instant.now()); + yield mpesaRequests.save(entity).getId(); + } + case AIRTEL -> { + AirtelRequest entity = new AirtelRequest(); + entity.setProvider(provider); + entity.setOperation(operation); + entity.setInitiationId(initiationId); + entity.setHttpMethod(httpMethod); + entity.setUrl(url); + entity.setRequestBody(body); + entity.setCreatedAt(Instant.now()); + yield airtelRequests.save(entity).getId(); + } + case MTN -> { + MtnRequest entity = new MtnRequest(); + entity.setProvider(provider); + entity.setOperation(operation); + entity.setInitiationId(initiationId); + entity.setHttpMethod(httpMethod); + entity.setUrl(url); + entity.setRequestBody(body); + entity.setCreatedAt(Instant.now()); + yield mtnRequests.save(entity).getId(); + } + }; + } + + /** + * getReferenceById rather than findById: this only needs the foreign key, so + * there is no reason to read the request row back out again. + */ + private void saveResponse(PaymentProviderType provider, Long requestId, Integer httpStatus, + String body, String failure, long durationMs) { + switch (provider.operator()) { + case MPESA -> { + MpesaResponse entity = new MpesaResponse(); + entity.setRequest(mpesaRequests.getReferenceById(requestId)); + entity.setHttpStatus(httpStatus); + entity.setResponseBody(body); + entity.setError(failure); + entity.setDurationMs(durationMs); + entity.setCreatedAt(Instant.now()); + mpesaResponses.save(entity); + } + case AIRTEL -> { + AirtelResponse entity = new AirtelResponse(); + entity.setRequest(airtelRequests.getReferenceById(requestId)); + entity.setHttpStatus(httpStatus); + entity.setResponseBody(body); + entity.setError(failure); + entity.setDurationMs(durationMs); + entity.setCreatedAt(Instant.now()); + airtelResponses.save(entity); + } + case MTN -> { + MtnResponse entity = new MtnResponse(); + entity.setRequest(mtnRequests.getReferenceById(requestId)); + entity.setHttpStatus(httpStatus); + entity.setResponseBody(body); + entity.setError(failure); + entity.setDurationMs(durationMs); + entity.setCreatedAt(Instant.now()); + mtnResponses.save(entity); + } + } + } + + private void stampReference(PaymentProviderType provider, Long requestId, String providerReference) { + switch (provider.operator()) { + case MPESA -> mpesaRequests.findById(requestId).ifPresent(request -> { + request.setProviderReference(providerReference); + mpesaRequests.save(request); + }); + case AIRTEL -> airtelRequests.findById(requestId).ifPresent(request -> { + request.setProviderReference(providerReference); + airtelRequests.save(request); + }); + case MTN -> mtnRequests.findById(requestId).ifPresent(request -> { + request.setProviderReference(providerReference); + mtnRequests.save(request); + }); + } + } + + private void saveCallback(PaymentProviderType provider, CallbackAudit data, String payload) { + String reference = data.providerReference(); + boolean matched; + + switch (provider.operator()) { + case MPESA -> { + MpesaRequest request = reference == null ? null + : first(mpesaRequests.findByProviderReference(reference)); + MpesaCallbackResponse entity = new MpesaCallbackResponse(); + entity.setRequest(request); + entity.setProvider(provider); + entity.setProviderReference(reference); + entity.setResultCode(data.resultCode()); + entity.setResultDesc(truncate(data.resultDesc(), 255)); + entity.setReceiptNumber(data.receiptNumber()); + entity.setAmount(data.amount()); + entity.setPhoneNumber(data.phoneNumber()); + entity.setTransactionDate(data.transactionDate()); + entity.setRawPayload(payload); + entity.setMatched(request != null); + entity.setCreatedAt(Instant.now()); + mpesaCallbacks.save(entity); + matched = request != null; + } + case AIRTEL -> { + AirtelRequest request = reference == null ? null + : first(airtelRequests.findByProviderReference(reference)); + AirtelCallbackResponse entity = new AirtelCallbackResponse(); + entity.setRequest(request); + entity.setProvider(provider); + entity.setProviderReference(reference); + entity.setResultCode(data.resultCode()); + entity.setResultDesc(truncate(data.resultDesc(), 255)); + entity.setReceiptNumber(data.receiptNumber()); + entity.setAmount(data.amount()); + entity.setPhoneNumber(data.phoneNumber()); + entity.setTransactionDate(data.transactionDate()); + entity.setRawPayload(payload); + entity.setMatched(request != null); + entity.setCreatedAt(Instant.now()); + airtelCallbacks.save(entity); + matched = request != null; + } + case MTN -> { + MtnRequest request = reference == null ? null + : first(mtnRequests.findByProviderReference(reference)); + MtnCallbackResponse entity = new MtnCallbackResponse(); + entity.setRequest(request); + entity.setProvider(provider); + entity.setProviderReference(reference); + entity.setResultCode(data.resultCode()); + entity.setResultDesc(truncate(data.resultDesc(), 255)); + entity.setReceiptNumber(data.receiptNumber()); + entity.setAmount(data.amount()); + entity.setPhoneNumber(data.phoneNumber()); + entity.setTransactionDate(data.transactionDate()); + entity.setRawPayload(payload); + entity.setMatched(request != null); + entity.setCreatedAt(Instant.now()); + mtnCallbacks.save(entity); + matched = request != null; + } + default -> matched = false; + } + + if (!matched) { + log.warn("[{}] callback for {} could not be matched to an outbound request", provider, reference); + } + } + + // --- helpers --------------------------------------------------------------- + + /** Head of an already-ordered result, or null when there is none. */ + private T first(java.util.List rows) { + return rows.isEmpty() ? null : rows.get(0); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + if (value instanceof String string) { + return string; + } + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + return ""; + } + } + + private String redact(String json) { + return json == null ? null : SECRETS.matcher(json).replaceAll("$1\"***\""); + } + + private String truncate(String value, int max) { + return value == null || value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/main/java/com/test/payment/service/ProviderMarkets.java b/src/main/java/com/test/payment/service/ProviderMarkets.java new file mode 100644 index 0000000..d8130b1 --- /dev/null +++ b/src/main/java/com/test/payment/service/ProviderMarkets.java @@ -0,0 +1,50 @@ +package com.test.payment.service; + +import com.test.payment.models.Country; +import com.test.payment.models.Operator; +import com.test.payment.models.PaymentProviderType; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Resolves which market each operator is configured for — {@code .country} + * in application.yml — into the market-qualified {@link PaymentProviderType} that + * payments are recorded against. + * + *

A bad or unsupported country fails here, at the first call, rather than as an + * opaque error from the provider's API. + */ +@Component +@RequiredArgsConstructor +public class ProviderMarkets { + + private static final Map DEFAULT_MARKET = new EnumMap<>(Map.of( + Operator.MPESA, Country.KE, + Operator.AIRTEL, Country.KE, + Operator.MTN, Country.UG)); + + private final Environment environment; + private final Map resolved = new EnumMap<>(Operator.class); + + public PaymentProviderType resolve(Operator operator) { + return resolved.computeIfAbsent(operator, this::read); + } + + /** Convenience for the many call sites that only need the stored provider name. */ + public String providerName(Operator operator) { + return resolve(operator).name(); + } + + private PaymentProviderType read(Operator operator) { + String key = operator.name().toLowerCase() + ".country"; + String configured = environment.getProperty(key, DEFAULT_MARKET.get(operator).name()); + Country country = Country.of(configured).orElseThrow(() -> new IllegalStateException( + "%s is '%s', which is not a known country — expected one of %s" + .formatted(key, configured, operator.countries()))); + return PaymentProviderType.require(operator, country); + } +} diff --git a/src/main/java/com/test/payment/service/StatusCatalog.java b/src/main/java/com/test/payment/service/StatusCatalog.java new file mode 100644 index 0000000..1800c5e --- /dev/null +++ b/src/main/java/com/test/payment/service/StatusCatalog.java @@ -0,0 +1,101 @@ +package com.test.payment.service; + +import com.test.payment.models.Status; +import com.test.payment.repository.StatusRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The lifecycle states, read from the STATUSES table rather than hard-coded as an + * enum — a state can be re-described, or new ones added, without a redeploy. + * + *

This is the only place that names the four states the code actually branches + * on. Everything else asks for one through {@link #pending()}, {@link #paid()}, + * {@link #success()} or {@link #failed()} and gets whichever row is currently in the + * table; the rows are memoised, since a status transition should not cost a query. + */ +@Service +@RequiredArgsConstructor +public class StatusCatalog { + + /** A state the seeder should ensure exists. */ + public record StatusDefinition(String name, String description) { + } + + private static final String PAID = "Paid"; + private static final String SUCCESS = "Success"; + private static final String PENDING = "Pending"; + private static final String FAILED = "Failed"; + + /** + * What {@code statuses} is seeded with when a state is missing. The table is the + * source of truth from then on — edit a description there, not here. + */ + private static final List DEFAULTS = List.of( + new StatusDefinition(PAID, "Payment settled and confirmed — a provider receipt exists"), + new StatusDefinition(SUCCESS, "Provider reported the collection succeeded"), + new StatusDefinition(PENDING, "Pushed to the payer, awaiting confirmation or the provider callback"), + new StatusDefinition(FAILED, "Rejected by the provider, declined by the payer, or timed out")); + + private final StatusRepository statusRepository; + private final Map byName = new ConcurrentHashMap<>(); + + public List defaults() { + return DEFAULTS; + } + + /** Payment settled and confirmed — a provider receipt exists. */ + public Status paid() { + return require(PAID); + } + + /** Provider reported the collection succeeded. */ + public Status success() { + return require(SUCCESS); + } + + /** Pushed to the payer, awaiting their confirmation or the provider's callback. */ + public Status pending() { + return require(PENDING); + } + + /** Provider rejected the request, the payer declined, or the push timed out. */ + public Status failed() { + return require(FAILED); + } + + /** True when the payment is still open — decides whether to re-query the provider. */ + public boolean isPending(String statusName) { + return PENDING.equals(statusName); + } + + public boolean isPending(Status status) { + return status != null && isPending(status.getName()); + } + + /** The name the FAILED state is stored under, for the limit queries that exclude it. */ + public String failedName() { + return FAILED; + } + + /** + * Looks a state up by the name stored in the table. + * + * @throws IllegalStateException when it was never seeded — a configuration error, + * not something a caller can recover from + */ + public Status require(String name) { + return byName.computeIfAbsent(name, key -> statusRepository.findByName(key) + .orElseThrow(() -> new IllegalStateException( + "Status '" + key + "' is missing from the statuses table — was the seeder skipped?"))); + } + + /** Drops the memoised rows; used by the seeder after it inserts. */ + public void invalidate() { + byName.clear(); + } +} diff --git a/src/main/java/com/test/payment/service/TokenCacheService.java b/src/main/java/com/test/payment/service/TokenCacheService.java index 7690e3f..280fabe 100644 --- a/src/main/java/com/test/payment/service/TokenCacheService.java +++ b/src/main/java/com/test/payment/service/TokenCacheService.java @@ -1,5 +1,6 @@ package com.test.payment.service; +import com.test.payment.models.Operator; import com.test.payment.models.ProviderToken; import com.test.payment.repository.ProviderTokenRepository; import lombok.RequiredArgsConstructor; @@ -8,9 +9,11 @@ import org.springframework.core.env.Environment; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.time.Duration; import java.time.LocalDateTime; +import java.util.concurrent.Callable; import java.util.function.Supplier; /** @@ -29,10 +32,9 @@ public class TokenCacheService { private final ReactiveStringRedisTemplate redisTemplate; private final Environment environment; - public record FetchedToken(String accessToken, long expiresInSeconds) { - } + public record FetchedToken(String accessToken, long expiresInSeconds) { } - public Mono getToken(String provider, Supplier> fetcher) { + public Mono getToken(Operator provider, Supplier> fetcher) { return fromRedis(provider) .switchIfEmpty(Mono.defer(() -> fromDatabase(provider))) .switchIfEmpty(Mono.defer(() -> fetchAndStore(provider, fetcher))); @@ -41,17 +43,21 @@ public class TokenCacheService { /** * Drops the cached token from Redis and the database (used on 401 from the provider). */ - public Mono evictToken(String provider) { + public Mono evictToken(Operator provider) { return redisTemplate.opsForValue().delete(redisKey(provider)) .timeout(REDIS_TIMEOUT) .onErrorResume(e -> { log.warn("[{}] Redis unavailable while evicting token: {}", provider, e.toString()); return Mono.just(false); }) - .then(tokenRepository.deleteByProvider(provider)); + .then(blocking(() -> { + tokenRepository.deleteByProvider(provider); + return true; + })) + .then(); } - private Mono fromRedis(String provider) { + private Mono fromRedis(Operator provider) { return redisTemplate.opsForValue().get(redisKey(provider)) .timeout(REDIS_TIMEOUT) .doOnNext(t -> log.debug("[{}] token served from Redis", provider)) @@ -61,9 +67,10 @@ public class TokenCacheService { }); } - private Mono fromDatabase(String provider) { + private Mono fromDatabase(Operator provider) { int buffer = expiryBufferSeconds(); - return tokenRepository.findFirstByProviderAndExpiresAtAfterOrderByIdDesc(provider, LocalDateTime.now().plusSeconds(buffer)) + return blocking(() -> tokenRepository.findUsable(provider, LocalDateTime.now().plusSeconds(buffer))) + .flatMap(tokens -> tokens.isEmpty() ? Mono.empty() : Mono.just(tokens.get(0))) .flatMap(token -> { long ttl = Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds() - buffer; log.debug("[{}] token served from database", provider); @@ -71,23 +78,23 @@ public class TokenCacheService { }); } - private Mono fetchAndStore(String provider, Supplier> fetcher) { + private Mono fetchAndStore(Operator provider, Supplier> fetcher) { return fetcher.get() .flatMap(fetched -> { ProviderToken token = ProviderToken.builder() - .provider(provider) - .accessToken(fetched.accessToken()) - .expiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds())) - .createdAt(LocalDateTime.now()) + .Provider(provider) + .AccessToken(fetched.accessToken()) + .ExpiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds())) + .CreatedAt(LocalDateTime.now()) .build(); log.info("[{}] fetched new access token (expires in {}s)", provider, fetched.expiresInSeconds()); - return tokenRepository.save(token) + return blocking(() -> tokenRepository.save(token)) .then(cacheInRedis(provider, fetched.accessToken(), fetched.expiresInSeconds() - expiryBufferSeconds())) .thenReturn(fetched.accessToken()); }); } - private Mono cacheInRedis(String provider, String token, long ttlSeconds) { + private Mono cacheInRedis(Operator provider, String token, long ttlSeconds) { if (ttlSeconds <= 0) { return Mono.empty(); } @@ -101,11 +108,16 @@ public class TokenCacheService { .then(); } - private String redisKey(String provider) { - return provider.toLowerCase() + ":access_token"; + private String redisKey(Operator provider) { + return provider.name().toLowerCase() + ":access_token"; } private int expiryBufferSeconds() { return environment.getProperty("payments.token-expiry-buffer-seconds", Integer.class, 60); } + + /** The token table is JPA now, so its reads and writes go off the event loop. */ + private Mono blocking(Callable work) { + return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 42bc6d9..78be04e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,10 +47,16 @@ resilience4j: mtnCircuitBreaker: *provider-circuit-breaker spring: - r2dbc: - url: r2dbc:h2:mem:///mpesa_db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - username: sa - password: + datasource: + url: jdbc:postgresql://localhost:5432/payments + username: myapp + password: your_password + jpa: + hibernate: + ddl-auto: update + # WebFlux has no OSIV filter anyway; keeping it off makes lazy-loading + # outside a transaction fail loudly instead of on a random worker thread. + open-in-view: false main: web-application-type: reactive data: @@ -62,7 +68,7 @@ spring: logging: level: - org.springframework.data.r2dbc: DEBUG + org.hibernate.SQL: DEBUG springdoc: swagger-ui: @@ -82,6 +88,8 @@ payments: fixed-delay: 60s mpesa: + # market this operator is wired for -> provider MPESA_KE + country: KE base-url: https://sandbox.safaricom.co.ke consumer-key: k6e7LtBNeVX7V8MPqB7P83FsZio8cRZD consumer-secret: cGwiWzhDGopC3dho @@ -90,13 +98,16 @@ mpesa: callback-url: https://mydomain.com/api/mpesa/callback airtel: + # KE -> AIRTEL_KE, UG -> AIRTEL_UG, ... + country: KE base-url: https://openapiuat.airtel.africa client-id: REPLACE_WITH_AIRTEL_CLIENT_ID client-secret: REPLACE_WITH_AIRTEL_CLIENT_SECRET - country: KE currency: KES mtn: + # UG -> MTN_UG, GH -> MTN_GH, ... + country: UG base-url: https://sandbox.momodeveloper.mtn.com subscription-key: REPLACE_WITH_MTN_SUBSCRIPTION_KEY api-user: REPLACE_WITH_MTN_API_USER