Updates and payments additions
This commit is contained in:
65
.gitignore
vendored
65
.gitignore
vendored
@@ -1,16 +1,47 @@
|
||||
# Compiled class file
|
||||
*.class
|
||||
# --- Gradle ---
|
||||
build/
|
||||
.gradle/
|
||||
|
||||
# Log file
|
||||
# --- IDE: IntelliJ ---
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
out/
|
||||
|
||||
# --- IDE: Eclipse / STS ---
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings/
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
|
||||
# --- IDE: VS Code ---
|
||||
.vscode/
|
||||
|
||||
# --- IDE: NetBeans ---
|
||||
/nbproject/private/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
# --- OS ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Logs & temp ---
|
||||
*.log
|
||||
logs/
|
||||
nohup.out
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
# --- Compiled / packaged ---
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
@@ -18,7 +49,15 @@
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
*.ctxt
|
||||
.mtj.tmp/
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
# Keep the Gradle wrapper despite the *.jar rule above (last match wins)
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
# --- Local env / secrets (keep real credentials out of the repo) ---
|
||||
.env
|
||||
.env.*
|
||||
application-local.yml
|
||||
application-local.yaml
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Fri Oct 10 12:56:24 EAT 2025
|
||||
gradle.version=8.14.3
|
||||
Binary file not shown.
Binary file not shown.
5
.idea/.gitignore
generated
vendored
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Environment-dependent path to Maven home directory
|
||||
/mavenHomeManager.xml
|
||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
||||
payment
|
||||
18
.idea/compiler.xml
generated
18
.idea/compiler.xml
generated
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<annotationProcessing>
|
||||
<profile name="Gradle Imported" enabled="true">
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<processorPath useClasspath="false">
|
||||
<entry name="$USER_HOME$/.gradle/caches/modules-2/files-2.1/org.projectlombok/lombok/1.18.32/17d46b3e205515e1e8efd3ee4d57ce8018914163/lombok-1.18.32.jar" />
|
||||
</processorPath>
|
||||
<module name="payment.main" />
|
||||
</profile>
|
||||
</annotationProcessing>
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
<component name="JavacSettings">
|
||||
<option name="ADDITIONAL_OPTIONS_STRING" value="-parameters" />
|
||||
</component>
|
||||
</project>
|
||||
15
.idea/gradle.xml
generated
15
.idea/gradle.xml
generated
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
5
.idea/misc.xml
generated
5
.idea/misc.xml
generated
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="ms-17" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/payment.main.iml" filepath="$PROJECT_DIR$/.idea/modules/payment.main.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules/payment.main.iml
generated
8
.idea/modules/payment.main.iml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="AdditionalModuleElements">
|
||||
<content url="file://$MODULE_DIR$/../../build/generated/sources/annotationProcessor/java/main">
|
||||
<sourceFolder url="file://$MODULE_DIR$/../../build/generated/sources/annotationProcessor/java/main" isTestSource="false" generated="true" />
|
||||
</content>
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
./gradlew build
|
||||
|
||||
# Run the application
|
||||
./gradlew bootRun
|
||||
|
||||
# Run tests
|
||||
./gradlew test
|
||||
|
||||
# Run a single test class
|
||||
./gradlew test --tests "com.test.payment.PaymentApplicationTests"
|
||||
|
||||
# Clean build
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
Requires JDK 21 to run Gradle (Gradle 8.14 cannot run on JDK 25; set `JAVA_HOME` accordingly).
|
||||
The app starts on the default port 8080. Swagger UI is available at `http://localhost:8080/swagger-ui.html`.
|
||||
Redis is optional at runtime (`docker run -d --name mpesa-redis -p 6379:6379 redis:7-alpine`) — the app degrades gracefully without it.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service (M-Pesa STK Push, Airtel Money, MTN MoMo), using R2DBC for async DB access, Redis (Lettuce) for token caching, and Resilience4j for fault tolerance.
|
||||
|
||||
**Layering (same shape for every provider):**
|
||||
`<Provider>Controller` → `<Provider>Service` (implements `PaymentProviderService`; parses provider payloads) → `PaymentLifecycleService` (ALL persistence: initiation/response/callback/transaction, dedup, status transitions) and `<Provider>Client` (resilience-wrapped HTTP) → provider API.
|
||||
|
||||
**Endpoints** (identical pattern per provider under `/api/mpesa`, `/api/airtel`, `/api/mtn`):
|
||||
- `POST /pay` — validated request; persists an initiation, calls the provider, persists the linked response, returns the outcome with a `providerReference`.
|
||||
- `POST /callback` — provider result callback (MTN also accepts PUT); stored 1:1 with the initiation, deduplicated, updates status. Always acks.
|
||||
- `GET /status/{providerReference}` — DB state; if still PENDING, performs a live provider status query and updates the DB. Degrades to last-known state when the provider rate-limits.
|
||||
- `GET /api/payments/transactions?provider=` — consolidated transactions across providers.
|
||||
|
||||
**Provider specifics:**
|
||||
- M-Pesa: `providerReference` = CheckoutRequestID, `secondaryReference` = MerchantRequestID; STK query "still processing" (errorCode 500.001.1001) maps to `ProviderProcessingException` → stays PENDING. Sandbox creds in yml are live.
|
||||
- Airtel: reference is a generated `ATL<uuid>` transaction id; status codes TIP (pending) / TS (success) / TF (failed); receipt = `airtel_money_id`. Credentials are placeholders (`airtel.*`).
|
||||
- MTN MoMo: reference is the generated `X-Reference-Id` UUID (externalId is set to the same value for callback correlation); request-to-pay returns 202 with no body; status SUCCESSFUL/FAILED/PENDING; receipt = `financialTransactionId`. Sandbox needs `mtn.subscription-key`, `mtn.api-user`, `mtn.api-key`; sandbox currency is EUR. Placeholders in yml.
|
||||
|
||||
**Key design points:**
|
||||
|
||||
- All I/O is non-blocking (`Mono`/`Flux` throughout). Never block a reactive pipeline with `.block()` (startup schema init is the one deliberate exception).
|
||||
- All outbound provider calls live in `client/*Client` classes so the Resilience4j annotations (`@CircuitBreaker`, `@RateLimiter`, `@Retry`) go through Spring AOP — they are silently skipped on self-invocation. Each provider has its own instances (`mpesa*`, `airtel*`, `mtn*`) configured **only** in `application.yml` (YAML anchors share the retry/circuit-breaker settings).
|
||||
- Shared exception model in `exceptions/`: `ProviderBusyException` (429/busy → reactive backoff retry, 503), `ProviderTransientException` (Resilience4j retry, 502), `ProviderPermanentException` (never retried, CB-ignored, 400), `ProviderProcessingException` (still processing → stays PENDING). HTTP-to-exception mapping is shared in `client/ProviderHttpErrors` (401 evicts the token so the retry refetches).
|
||||
- `TokenCacheService` is the tiered token cache for all providers: Redis (`<provider>:access_token`) → `PROVIDER_TOKENS` table → live OAuth fetch (persisted to both tiers). Redis being down never fails a request (800ms timeouts, falls through). `<Provider>TokenService` classes only supply the fetch call.
|
||||
- Boot 4 defaults to Jackson 3 (`tools.jackson.databind.ObjectMapper`) — inject that type, not `com.fasterxml`. The `com.fasterxml.jackson.annotation.*` annotations still work.
|
||||
- Resilience4j 2.4.0 with `resilience4j-spring-boot4`; annotations require `aspectjweaver` (Boot 4 removed `spring-boot-starter-aop`). Boot 4 also split `WebClient` auto-config into `spring-boot-starter-webclient`.
|
||||
- Package convention: `models` holds only database entities (`@Table`) plus `DatabaseSchema`; everything crossing an API boundary lives in `dto`.
|
||||
|
||||
**Persistence (in-memory H2 via R2DBC):**
|
||||
- The schema lives in code: `models/DatabaseSchema.STATEMENTS`, executed at startup by `configurations/DatabaseSchemaInitializer` (there is no schema.sql; `spring.sql.init` is not used).
|
||||
- `PAYMENT_INITIATIONS` — one row per payment attempt, any provider (status: PENDING → SUCCESS/FAILED).
|
||||
- `PAYMENT_RESPONSES` — the provider's answer, `initiation_id UNIQUE` (1:1) and `provider_reference UNIQUE` (lookup key for callbacks/status).
|
||||
- `PAYMENT_CALLBACKS` — the provider result callback, `initiation_id UNIQUE`, duplicates ignored, raw payload stored as JSON.
|
||||
- `TRANSACTIONS` — consolidated record upserted by `PaymentLifecycleService.recordTransaction` whenever an initiation reaches a terminal state, from whichever path resolved it (`resolvedBy`: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION). `initiation_id UNIQUE`.
|
||||
- `PROVIDER_TOKENS` — OAuth tokens per provider with expiry.
|
||||
- `@Table` names must be UPPERCASE — H2 stores unquoted DDL identifiers uppercase and Spring Data quotes entity names verbatim.
|
||||
- `PaymentReconciliationJob` reconciles PENDING initiations of **all** providers older than `payments.reconciliation.pending-age` (default 5m) by dispatching to the right `PaymentProviderService`; interval `payments.reconciliation.fixed-delay` (default 60s).
|
||||
|
||||
**Configuration (`application.yml`):**
|
||||
- Config is read via `Environment.getProperty` by project convention (no `@ConfigurationProperties`).
|
||||
- `mpesa.*` (live sandbox keys), `airtel.*` and `mtn.*` (placeholders — fill in real credentials), `payments.*` (token buffer, reconciliation).
|
||||
- Callback URLs (`mpesa.callback-url`, `mtn.callback-url`) must be publicly reachable for real callbacks.
|
||||
- `spring.data.redis.*` — short 1s timeouts so a dead Redis degrades fast.
|
||||
|
||||
**Sandbox gotchas (observed live):**
|
||||
- Safaricom's STK query endpoint is aggressively rate-limited (~few calls/minute) — expect `ProviderBusyException` from status checks/reconciliation during testing; the status endpoint then serves last-known DB state.
|
||||
- M-Pesa test phone 254708374149 typically ends as ResultCode 1037 ("DS timeout user cannot be reached") since no real handset confirms the push.
|
||||
- Airtel/MTN flows were verified against a local mock of their APIs (`scratchpad/provider_mock.py` pattern) — point `airtel.base-url`/`mtn.base-url` at a stub to test without credentials.
|
||||
24
build.gradle
24
build.gradle
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.6'
|
||||
id 'org.springframework.boot' version '4.1.0'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ description = 'Demo project for Spring Boot'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,29 +21,27 @@ 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-redis-reactive")
|
||||
*/implementation("io.lettuce:lettuce-core:6.2.2.RELEASE")
|
||||
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
|
||||
|
||||
implementation("io.github.resilience4j:resilience4j-ratelimiter:2.0.2")
|
||||
implementation("io.github.resilience4j:resilience4j-circuitbreaker:2.0.2")
|
||||
implementation("io.github.resilience4j:resilience4j-retry:2.0.2")
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.6.0")
|
||||
implementation("org.aspectj:aspectjweaver")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.3")
|
||||
|
||||
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1'
|
||||
implementation 'io.github.resilience4j:resilience4j-reactor:1.7.1'
|
||||
implementation 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-reactor:2.4.0'
|
||||
|
||||
|
||||
|
||||
implementation 'org.projectlombok:lombok:1.18.32' // Use the latest stable version
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32' // For annotation processing
|
||||
implementation 'org.projectlombok:lombok:1.18.46' // Use the latest stable version
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.46' // For annotation processing
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,70 +0,0 @@
|
||||
#spring:
|
||||
# redis:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
|
||||
resilience4j:
|
||||
ratelimiter:
|
||||
instances:
|
||||
mpesaLimiter:
|
||||
limit-for-period: 10
|
||||
limit-refresh-period: 1s
|
||||
timeout-duration: 0s
|
||||
|
||||
retry:
|
||||
instances:
|
||||
mpesaRetry:
|
||||
max-attempts: 3
|
||||
wait-duration: 2s
|
||||
retry-exceptions:
|
||||
- org.springframework.web.reactive.function.client.WebClientRequestException
|
||||
- java.io.IOException
|
||||
- com.example.mpesa.exceptions.MpesaBusyException
|
||||
ignore-exceptions:
|
||||
- com.test.payment.exceptions.MpesaPermanentException
|
||||
- java.lang.IllegalArgumentException
|
||||
|
||||
|
||||
circuitbreaker:
|
||||
instances:
|
||||
mpesaCircuitBreaker:
|
||||
sliding-window-size: 10
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 10s
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
spring:
|
||||
r2dbc:
|
||||
url: r2dbc:h2:mem:///mpesa_db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
username: sa
|
||||
password:
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations: classpath:schema.sql
|
||||
main:
|
||||
web-application-type: reactive
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.data.r2dbc: DEBUG
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
operationsSorter: method
|
||||
tagsSorter: alpha
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
packages-to-scan: com.test.payment.controller
|
||||
|
||||
mpesa:
|
||||
base-url: https://sandbox.safaricom.co.ke
|
||||
consumer-key: k6e7LtBNeVX7V8MPqB7P83FsZio8cRZD
|
||||
consumer-secret: cGwiWzhDGopC3dho
|
||||
business-short-code: 174379
|
||||
pass-key: bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mpesa_reference VARCHAR(255),
|
||||
checkout_request_id VARCHAR(255),
|
||||
status VARCHAR(50),
|
||||
amount DECIMAL(10,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
69
src/main/java/com/test/payment/client/AirtelClient.java
Normal file
69
src/main/java/com/test/payment/client/AirtelClient.java
Normal file
@@ -0,0 +1,69 @@
|
||||
package com.test.payment.client;
|
||||
|
||||
import com.test.payment.dto.AirtelPaymentRequestDto;
|
||||
import com.test.payment.dto.AirtelResponseDto;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.service.AirtelTokenService;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AirtelClient {
|
||||
|
||||
private final WebClient airtelWebClient;
|
||||
private final AirtelTokenService tokenService;
|
||||
private final Environment environment;
|
||||
|
||||
@CircuitBreaker(name = "airtelCircuitBreaker")
|
||||
@RateLimiter(name = "airtelLimiter")
|
||||
@Retry(name = "airtelRetry")
|
||||
public Mono<AirtelResponseDto> pay(AirtelPaymentRequestDto request) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> airtelWebClient.post()
|
||||
.uri("/merchant/v1/payments/")
|
||||
.headers(h -> {
|
||||
h.setBearerAuth(token);
|
||||
h.set("X-Country", environment.getProperty("airtel.country", "KE"));
|
||||
h.set("X-Currency", environment.getProperty("airtel.currency", "KES"));
|
||||
})
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError,
|
||||
resp -> ProviderHttpErrors.map(resp, "AIRTEL", tokenService.evictToken()))
|
||||
.bodyToMono(AirtelResponseDto.class))
|
||||
.retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3))
|
||||
.maxBackoff(Duration.ofSeconds(30))
|
||||
.jitter(0.5)
|
||||
.filter(ProviderBusyException.class::isInstance)
|
||||
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
|
||||
}
|
||||
|
||||
@CircuitBreaker(name = "airtelCircuitBreaker")
|
||||
@RateLimiter(name = "airtelLimiter")
|
||||
public Mono<AirtelResponseDto> status(String transactionId) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> airtelWebClient.get()
|
||||
.uri("/standard/v1/payments/{id}", transactionId)
|
||||
.headers(h -> {
|
||||
h.setBearerAuth(token);
|
||||
h.set("X-Country", environment.getProperty("airtel.country", "KE"));
|
||||
h.set("X-Currency", environment.getProperty("airtel.currency", "KES"));
|
||||
})
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError,
|
||||
resp -> ProviderHttpErrors.map(resp, "AIRTEL", tokenService.evictToken()))
|
||||
.bodyToMono(AirtelResponseDto.class));
|
||||
}
|
||||
}
|
||||
82
src/main/java/com/test/payment/client/MpesaClient.java
Normal file
82
src/main/java/com/test/payment/client/MpesaClient.java
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.test.payment.client;
|
||||
|
||||
import com.test.payment.dto.MpesaRequestDto;
|
||||
import com.test.payment.dto.MpesaResponse;
|
||||
import com.test.payment.dto.StkQueryRequestDto;
|
||||
import com.test.payment.dto.StkQueryResponseDto;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.exceptions.ProviderProcessingException;
|
||||
import com.test.payment.service.MpesaTokenService;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* All outbound Safaricom calls live here so the Resilience4j annotations are applied
|
||||
* through Spring AOP (they would be silently skipped on self-invocation inside a service).
|
||||
*
|
||||
* The Resilience4j @Retry handles network-level failures; the reactive backoff retry
|
||||
* below handles ProviderBusyException (rate limiting / "system busy"). Both coexist
|
||||
* intentionally.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaClient {
|
||||
|
||||
private final WebClient mpesaWebClient;
|
||||
private final MpesaTokenService tokenService;
|
||||
|
||||
@CircuitBreaker(name = "mpesaCircuitBreaker")
|
||||
@RateLimiter(name = "mpesaLimiter")
|
||||
@Retry(name = "mpesaRetry")
|
||||
public Mono<MpesaResponse> stkPush(MpesaRequestDto request) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.headers(h -> h.setBearerAuth(token))
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, resp -> mapError(resp, false))
|
||||
.bodyToMono(MpesaResponse.class))
|
||||
.retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3))
|
||||
.maxBackoff(Duration.ofSeconds(30))
|
||||
.jitter(0.5)
|
||||
.filter(ProviderBusyException.class::isInstance)
|
||||
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
|
||||
}
|
||||
|
||||
@CircuitBreaker(name = "mpesaCircuitBreaker")
|
||||
@RateLimiter(name = "mpesaLimiter")
|
||||
public Mono<StkQueryResponseDto> stkQuery(StkQueryRequestDto request) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpushquery/v1/query")
|
||||
.headers(h -> h.setBearerAuth(token))
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, resp -> mapError(resp, true))
|
||||
.bodyToMono(StkQueryResponseDto.class));
|
||||
}
|
||||
|
||||
private Mono<? extends Throwable> mapError(ClientResponse response, boolean isQuery) {
|
||||
if (isQuery && !response.statusCode().is2xxSuccessful()) {
|
||||
// "The transaction is being processed" arrives as an error body (500.001.1001)
|
||||
return response.bodyToMono(String.class)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(body -> body.contains("500.001.1001")
|
||||
? Mono.error(new ProviderProcessingException("Transaction is still being processed"))
|
||||
: ProviderHttpErrors.mapBody(response.statusCode(), body, "MPESA", tokenService.evictToken()));
|
||||
}
|
||||
return ProviderHttpErrors.map(response, "MPESA", tokenService.evictToken());
|
||||
}
|
||||
}
|
||||
80
src/main/java/com/test/payment/client/MtnClient.java
Normal file
80
src/main/java/com/test/payment/client/MtnClient.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.test.payment.client;
|
||||
|
||||
import com.test.payment.dto.MtnPayRequestDto;
|
||||
import com.test.payment.dto.MtnStatusResponseDto;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.service.MtnTokenService;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MtnClient {
|
||||
|
||||
private final WebClient mtnWebClient;
|
||||
private final MtnTokenService tokenService;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* MoMo request-to-pay returns 202 Accepted with an empty body; the referenceId
|
||||
* we generate is the id used for the status check and callback correlation.
|
||||
*/
|
||||
@CircuitBreaker(name = "mtnCircuitBreaker")
|
||||
@RateLimiter(name = "mtnLimiter")
|
||||
@Retry(name = "mtnRetry")
|
||||
public Mono<Void> requestToPay(String referenceId, MtnPayRequestDto request) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> mtnWebClient.post()
|
||||
.uri("/collection/v1_0/requesttopay")
|
||||
.headers(h -> {
|
||||
commonHeaders(h, token);
|
||||
h.set("X-Reference-Id", referenceId);
|
||||
String callbackUrl = environment.getProperty("mtn.callback-url", "");
|
||||
if (!callbackUrl.isBlank()) {
|
||||
h.set("X-Callback-Url", callbackUrl);
|
||||
}
|
||||
})
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError,
|
||||
resp -> ProviderHttpErrors.map(resp, "MTN", tokenService.evictToken()))
|
||||
.toBodilessEntity()
|
||||
.then())
|
||||
.retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3))
|
||||
.maxBackoff(Duration.ofSeconds(30))
|
||||
.jitter(0.5)
|
||||
.filter(ProviderBusyException.class::isInstance)
|
||||
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
|
||||
}
|
||||
|
||||
@CircuitBreaker(name = "mtnCircuitBreaker")
|
||||
@RateLimiter(name = "mtnLimiter")
|
||||
public Mono<MtnStatusResponseDto> status(String referenceId) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token -> mtnWebClient.get()
|
||||
.uri("/collection/v1_0/requesttopay/{referenceId}", referenceId)
|
||||
.headers(h -> commonHeaders(h, token))
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError,
|
||||
resp -> ProviderHttpErrors.map(resp, "MTN", tokenService.evictToken()))
|
||||
.bodyToMono(MtnStatusResponseDto.class));
|
||||
}
|
||||
|
||||
private void commonHeaders(HttpHeaders headers, String token) {
|
||||
headers.setBearerAuth(token);
|
||||
headers.set("X-Target-Environment", environment.getProperty("mtn.target-environment", "sandbox"));
|
||||
headers.set("Ocp-Apim-Subscription-Key", environment.getProperty("mtn.subscription-key", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.test.payment.client;
|
||||
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.exceptions.ProviderPermanentException;
|
||||
import com.test.payment.exceptions.ProviderTransientException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Shared HTTP error mapping for provider calls:
|
||||
* 429/busy body → ProviderBusyException (reactive backoff retries),
|
||||
* 401 → evict token + ProviderTransientException (Resilience4j retry refetches),
|
||||
* other 4xx → ProviderPermanentException (never retried),
|
||||
* 5xx → ProviderTransientException (retried).
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProviderHttpErrors {
|
||||
|
||||
private ProviderHttpErrors() {
|
||||
}
|
||||
|
||||
public static Mono<? extends Throwable> map(ClientResponse response, String provider, Mono<Void> evictToken) {
|
||||
HttpStatusCode status = response.statusCode();
|
||||
if (status.value() == 429) {
|
||||
log.warn("[{}] rate limit (429) — will retry with backoff", provider);
|
||||
return Mono.error(new ProviderBusyException(provider + " rate limit exceeded"));
|
||||
}
|
||||
return response.bodyToMono(String.class)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(body -> mapBody(status, body, provider, evictToken));
|
||||
}
|
||||
|
||||
public static Mono<? extends Throwable> mapBody(HttpStatusCode status, String body, String provider, Mono<Void> evictToken) {
|
||||
log.error("[{}] returned {} with body: {}", provider, status, body);
|
||||
String lower = body.toLowerCase();
|
||||
if (status.value() == 429
|
||||
|| lower.contains("system is busy")
|
||||
|| lower.contains("too many requests")
|
||||
|| lower.contains("rate limit")
|
||||
|| lower.contains("429.001")) {
|
||||
return Mono.error(new ProviderBusyException(provider + " busy: " + body));
|
||||
}
|
||||
if (status.value() == 401) {
|
||||
// stored token is stale/invalid — evict so the retry fetches a fresh one
|
||||
return evictToken.then(Mono.error(new ProviderTransientException(
|
||||
provider + " rejected the access token (401); token evicted for refresh", null)));
|
||||
}
|
||||
if (status.is4xxClientError()) {
|
||||
return Mono.error(new ProviderPermanentException(provider + " rejected the request: " + body));
|
||||
}
|
||||
return Mono.error(new ProviderTransientException(provider + " error " + status + ": " + body, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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()))
|
||||
.then()
|
||||
.block();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.test.payment.configurations;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
//import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
//import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
//@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/* @Bean
|
||||
public LettuceConnectionFactory redisConnectionFactory() {
|
||||
return new LettuceConnectionFactory();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(LettuceConnectionFactory factory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
return template;
|
||||
}*/
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.test.payment.configurations;
|
||||
|
||||
import com.test.payment.exceptions.MpesaPermanentException;
|
||||
import io.github.resilience4j.circuitbreaker.*;
|
||||
import io.github.resilience4j.ratelimiter.*;
|
||||
import io.github.resilience4j.retry.*;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClientRequestException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class ResilienceConfig {
|
||||
|
||||
@Bean
|
||||
public RateLimiter rateLimiter() {
|
||||
return RateLimiter.of("mpesaLimiter", RateLimiterConfig.custom()
|
||||
.limitForPeriod(10)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ZERO)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Retry retry() {
|
||||
return Retry.of("mpesaRetry", RetryConfig.custom()
|
||||
.maxAttempts(3)
|
||||
.waitDuration(Duration.ofSeconds(2))
|
||||
.retryExceptions(WebClientRequestException.class, IOException.class)
|
||||
.ignoreExceptions(MpesaPermanentException.class)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CircuitBreaker circuitBreaker() {
|
||||
return CircuitBreaker.of("mpesaCircuitBreaker", CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(50)
|
||||
.waitDurationInOpenState(Duration.ofSeconds(10))
|
||||
.permittedNumberOfCallsInHalfOpenState(3)
|
||||
.slidingWindowSize(10)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,40 @@
|
||||
package com.test.payment.configurations;
|
||||
|
||||
import io.netty.channel.ChannelOption;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient mpesaWebClient(WebClient.Builder builder) {
|
||||
public WebClient mpesaWebClient(WebClient.Builder builder, Environment environment) {
|
||||
return providerClient(builder, environment.getProperty("mpesa.base-url", "https://sandbox.safaricom.co.ke"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebClient airtelWebClient(WebClient.Builder builder, Environment environment) {
|
||||
return providerClient(builder, environment.getProperty("airtel.base-url", "https://openapiuat.airtel.africa"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebClient mtnWebClient(WebClient.Builder builder, Environment environment) {
|
||||
return providerClient(builder, environment.getProperty("mtn.base-url", "https://sandbox.momodeveloper.mtn.com"));
|
||||
}
|
||||
|
||||
private WebClient providerClient(WebClient.Builder builder, String baseUrl) {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
|
||||
.responseTimeout(Duration.ofSeconds(15));
|
||||
return builder
|
||||
.baseUrl("https://sandbox.safaricom.co.ke")
|
||||
.baseUrl(baseUrl)
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.dto.AirtelCallbackPayload;
|
||||
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.service.AirtelService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/airtel")
|
||||
@RequiredArgsConstructor
|
||||
public class AirtelController {
|
||||
|
||||
private final AirtelService airtelService;
|
||||
|
||||
@PostMapping("/pay")
|
||||
public Mono<PaymentResultDto> pay(@Valid @RequestBody PaymentRequest request) {
|
||||
return airtelService.initiatePayment(request);
|
||||
}
|
||||
|
||||
@PostMapping("/callback")
|
||||
public Mono<CallbackAckDto> callback(@RequestBody AirtelCallbackPayload payload) {
|
||||
return airtelService.handleCallback(payload);
|
||||
}
|
||||
|
||||
@GetMapping("/status/{transactionId}")
|
||||
public Mono<TransactionStatusDto> status(@PathVariable String transactionId) {
|
||||
return airtelService.checkStatus(transactionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.dto.ErrorResponseDto;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.exceptions.ProviderPermanentException;
|
||||
import com.test.payment.exceptions.ProviderTransientException;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.bind.support.WebExchangeBindException;
|
||||
import org.springframework.web.reactive.function.client.WebClientRequestException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Every failure still returns a structured JSON value to the caller,
|
||||
* with an HTTP status that reflects what actually happened upstream.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(CallNotPermittedException.class)
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public ErrorResponseDto circuitOpen(CallNotPermittedException ex) {
|
||||
log.warn("Circuit breaker open: {}", ex.getMessage());
|
||||
return ErrorResponseDto.of("CIRCUIT_OPEN", "Payment service temporarily unavailable, please retry shortly");
|
||||
}
|
||||
|
||||
@ExceptionHandler(RequestNotPermitted.class)
|
||||
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
|
||||
public ErrorResponseDto rateLimited(RequestNotPermitted ex) {
|
||||
return ErrorResponseDto.of("RATE_LIMITED", "Too many payment requests, please retry shortly");
|
||||
}
|
||||
|
||||
@ExceptionHandler(ProviderBusyException.class)
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public ErrorResponseDto providerBusy(ProviderBusyException ex) {
|
||||
return ErrorResponseDto.of("PROVIDER_BUSY", "The payment provider is busy, please retry shortly");
|
||||
}
|
||||
|
||||
@ExceptionHandler(ProviderPermanentException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ErrorResponseDto providerRejected(ProviderPermanentException ex) {
|
||||
return ErrorResponseDto.of("PROVIDER_REJECTED", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ProviderTransientException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_GATEWAY)
|
||||
public ErrorResponseDto providerError(ProviderTransientException ex) {
|
||||
return ErrorResponseDto.of("PROVIDER_ERROR", "Payment provider request failed, please retry");
|
||||
}
|
||||
|
||||
@ExceptionHandler(WebClientRequestException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_GATEWAY)
|
||||
public ErrorResponseDto providerUnreachable(WebClientRequestException ex) {
|
||||
log.error("Could not reach payment provider: {}", ex.getMessage());
|
||||
return ErrorResponseDto.of("PROVIDER_UNREACHABLE", "Could not reach the payment provider, please retry");
|
||||
}
|
||||
|
||||
@ExceptionHandler(WebExchangeBindException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ErrorResponseDto validationFailed(WebExchangeBindException ex) {
|
||||
List<String> details = ex.getFieldErrors().stream()
|
||||
.map(err -> err.getField() + ": " + err.getDefaultMessage())
|
||||
.toList();
|
||||
ErrorResponseDto body = ErrorResponseDto.of("VALIDATION_FAILED", "Request validation failed");
|
||||
body.setDetails(details);
|
||||
return body;
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponseDto> responseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(ErrorResponseDto.of(String.valueOf(ex.getStatusCode().value()), ex.getReason()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ErrorResponseDto unexpected(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ErrorResponseDto.of("INTERNAL_ERROR", "An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
|
||||
import com.test.payment.models.MpesaResponse;
|
||||
import com.test.payment.models.PaymentRequest;
|
||||
import com.test.payment.dto.CallbackAckDto;
|
||||
import com.test.payment.dto.PaymentRequest;
|
||||
import com.test.payment.dto.PaymentResultDto;
|
||||
import com.test.payment.dto.StkCallbackPayload;
|
||||
import com.test.payment.dto.TransactionStatusDto;
|
||||
import com.test.payment.service.MpesaService;
|
||||
import com.test.payment.service.MpesaServiceaa;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@@ -18,11 +24,21 @@ public class MpesaController {
|
||||
private final MpesaService mpesaService;
|
||||
|
||||
@PostMapping("/pay")
|
||||
public Mono<ResponseEntity<MpesaResponse>> pay(@RequestBody PaymentRequest request) {
|
||||
return mpesaService.initiatePayment(request)
|
||||
.map(ResponseEntity::ok)
|
||||
.onErrorResume(ex -> Mono.just(ResponseEntity.badRequest()
|
||||
.body(new MpesaResponse("FAILED", ex.getMessage(),"","",""))));
|
||||
public Mono<PaymentResultDto> pay(@Valid @RequestBody PaymentRequest request) {
|
||||
return mpesaService.initiatePayment(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safaricom posts the STK result here (mpesa.callback-url must point at this path).
|
||||
* Always acknowledges with ResultCode 0 so Safaricom does not keep retrying.
|
||||
*/
|
||||
@PostMapping("/callback")
|
||||
public Mono<CallbackAckDto> callback(@RequestBody StkCallbackPayload payload) {
|
||||
return mpesaService.handleCallback(payload);
|
||||
}
|
||||
|
||||
@GetMapping("/status/{checkoutRequestId}")
|
||||
public Mono<TransactionStatusDto> status(@PathVariable String checkoutRequestId) {
|
||||
return mpesaService.checkStatus(checkoutRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
50
src/main/java/com/test/payment/controller/MtnController.java
Normal file
50
src/main/java/com/test/payment/controller/MtnController.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.dto.CallbackAckDto;
|
||||
import com.test.payment.dto.MtnStatusResponseDto;
|
||||
import com.test.payment.dto.PaymentRequest;
|
||||
import com.test.payment.dto.PaymentResultDto;
|
||||
import com.test.payment.dto.TransactionStatusDto;
|
||||
import com.test.payment.service.MtnService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/mtn")
|
||||
@RequiredArgsConstructor
|
||||
public class MtnController {
|
||||
|
||||
private final MtnService mtnService;
|
||||
|
||||
@PostMapping("/pay")
|
||||
public Mono<PaymentResultDto> pay(@Valid @RequestBody PaymentRequest request) {
|
||||
return mtnService.initiatePayment(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoMo delivers the request-to-pay result here (mtn.callback-url); MTN may use
|
||||
* PUT or POST depending on the API version, so both are accepted.
|
||||
*/
|
||||
@PostMapping("/callback")
|
||||
public Mono<CallbackAckDto> callbackPost(@RequestBody MtnStatusResponseDto payload) {
|
||||
return mtnService.handleCallback(payload);
|
||||
}
|
||||
|
||||
@PutMapping("/callback")
|
||||
public Mono<CallbackAckDto> callbackPut(@RequestBody MtnStatusResponseDto payload) {
|
||||
return mtnService.handleCallback(payload);
|
||||
}
|
||||
|
||||
@GetMapping("/status/{referenceId}")
|
||||
public Mono<TransactionStatusDto> status(@PathVariable String referenceId) {
|
||||
return mtnService.checkStatus(referenceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.models.Transaction;
|
||||
import com.test.payment.service.PaymentLifecycleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payments")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentsController {
|
||||
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
|
||||
/**
|
||||
* All consolidated transactions across providers; optional ?provider=MPESA|AIRTEL|MTN filter.
|
||||
*/
|
||||
@GetMapping("/transactions")
|
||||
public Flux<Transaction> transactions(@RequestParam(required = false) String provider) {
|
||||
return lifecycle.listTransactions(provider == null ? null : provider.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Airtel Money result callback:
|
||||
* {"transaction":{"id":"<merchant tx id>","message":"...","status_code":"TS","airtel_money_id":"..."}}
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AirtelCallbackPayload {
|
||||
|
||||
private Transaction transaction;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Transaction {
|
||||
private String id;
|
||||
private String message;
|
||||
|
||||
@JsonProperty("status_code")
|
||||
private String statusCode;
|
||||
|
||||
@JsonProperty("airtel_money_id")
|
||||
private String airtelMoneyId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Airtel Money USSD-push collection request (POST /merchant/v1/payments/).
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AirtelPaymentRequestDto {
|
||||
|
||||
private String reference;
|
||||
private Subscriber subscriber;
|
||||
private Transaction transaction;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class Subscriber {
|
||||
private String country;
|
||||
private String currency;
|
||||
private String msisdn;
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class Transaction {
|
||||
private String amount;
|
||||
private String country;
|
||||
private String currency;
|
||||
private String id;
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/test/payment/dto/AirtelResponseDto.java
Normal file
45
src/main/java/com/test/payment/dto/AirtelResponseDto.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Airtel Money response envelope, shared by payment initiation and status enquiry.
|
||||
* Transaction status codes: TIP = in progress, TS = success, TF = failed.
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AirtelResponseDto {
|
||||
|
||||
private DataBody data;
|
||||
private Status status;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class DataBody {
|
||||
private Transaction transaction;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Transaction {
|
||||
private String id;
|
||||
private String status;
|
||||
private String message;
|
||||
|
||||
@JsonProperty("airtel_money_id")
|
||||
private String airtelMoneyId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Status {
|
||||
private String code;
|
||||
private String message;
|
||||
private Boolean success;
|
||||
|
||||
@JsonProperty("response_code")
|
||||
private String responseCode;
|
||||
}
|
||||
}
|
||||
25
src/main/java/com/test/payment/dto/CallbackAckDto.java
Normal file
25
src/main/java/com/test/payment/dto/CallbackAckDto.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Acknowledgement returned to Safaricom for a received STK callback.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class CallbackAckDto {
|
||||
|
||||
@JsonProperty("ResultCode")
|
||||
private int resultCode;
|
||||
|
||||
@JsonProperty("ResultDesc")
|
||||
private String resultDesc;
|
||||
|
||||
public static CallbackAckDto accepted(String desc) {
|
||||
return new CallbackAckDto(0, desc);
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/test/payment/dto/ErrorResponseDto.java
Normal file
24
src/main/java/com/test/payment/dto/ErrorResponseDto.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class ErrorResponseDto {
|
||||
|
||||
private String status;
|
||||
private String error;
|
||||
private String message;
|
||||
private List<String> details;
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
public static ErrorResponseDto of(String error, String message) {
|
||||
return new ErrorResponseDto("FAILED", error, message, null, LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.test.payment.models;
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
29
src/main/java/com/test/payment/dto/MtnPayRequestDto.java
Normal file
29
src/main/java/com/test/payment/dto/MtnPayRequestDto.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* MTN MoMo Collections request-to-pay body (POST /collection/v1_0/requesttopay).
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class MtnPayRequestDto {
|
||||
|
||||
private String amount;
|
||||
private String currency;
|
||||
private String externalId;
|
||||
private Payer payer;
|
||||
private String payerMessage;
|
||||
private String payeeNote;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class Payer {
|
||||
private String partyIdType;
|
||||
private String partyId;
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/test/payment/dto/MtnStatusResponseDto.java
Normal file
27
src/main/java/com/test/payment/dto/MtnStatusResponseDto.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* MTN MoMo request-to-pay resource (GET /collection/v1_0/requesttopay/{referenceId})
|
||||
* and the shape MTN posts to the callback URL. status: PENDING | SUCCESSFUL | FAILED.
|
||||
* The callback may carry referenceId; externalId is set by us to the same reference.
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MtnStatusResponseDto {
|
||||
|
||||
private String referenceId;
|
||||
private String externalId;
|
||||
private String amount;
|
||||
private String currency;
|
||||
private String status;
|
||||
private Object reason;
|
||||
private String financialTransactionId;
|
||||
private MtnPayRequestDto.Payer payer;
|
||||
|
||||
public String reasonText() {
|
||||
return reason == null ? null : String.valueOf(reason);
|
||||
}
|
||||
}
|
||||
28
src/main/java/com/test/payment/dto/OAuth2TokenResponse.java
Normal file
28
src/main/java/com/test/payment/dto/OAuth2TokenResponse.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Generic OAuth2 client-credentials token response (Airtel, MTN).
|
||||
* expires_in arrives as a number from MTN and sometimes a string from Airtel.
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class OAuth2TokenResponse {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
@JsonProperty("expires_in")
|
||||
private Object expiresIn;
|
||||
|
||||
public long expiresInSeconds() {
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(expiresIn).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 3600;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/main/java/com/test/payment/dto/PaymentRequest.java
Normal file
28
src/main/java/com/test/payment/dto/PaymentRequest.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PaymentRequest {
|
||||
|
||||
@NotBlank(message = "phoneNumber is required")
|
||||
@Pattern(regexp = "^\\d{9,14}$", message = "phoneNumber must be an MSISDN of 9-14 digits, e.g. 254712345678")
|
||||
private String phoneNumber;
|
||||
|
||||
@NotNull(message = "amount is required")
|
||||
@Min(value = 1, message = "amount must be at least 1")
|
||||
private Integer amount;
|
||||
|
||||
@NotBlank(message = "accountReference is required")
|
||||
@Size(max = 12, message = "accountReference must be at most 12 characters")
|
||||
private String accountReference;
|
||||
|
||||
@NotBlank(message = "transactionDesc is required")
|
||||
@Size(max = 13, message = "transactionDesc must be at most 13 characters")
|
||||
private String transactionDesc;
|
||||
}
|
||||
22
src/main/java/com/test/payment/dto/PaymentResultDto.java
Normal file
22
src/main/java/com/test/payment/dto/PaymentResultDto.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class PaymentResultDto {
|
||||
|
||||
private Long initiationId;
|
||||
private String provider;
|
||||
private String status;
|
||||
private String providerReference;
|
||||
private String secondaryReference;
|
||||
private String responseCode;
|
||||
private String responseDescription;
|
||||
private String customerMessage;
|
||||
}
|
||||
63
src/main/java/com/test/payment/dto/StkCallbackPayload.java
Normal file
63
src/main/java/com/test/payment/dto/StkCallbackPayload.java
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Safaricom STK Push result callback payload:
|
||||
* {"Body":{"stkCallback":{"MerchantRequestID":...,"CheckoutRequestID":...,"ResultCode":0,
|
||||
* "ResultDesc":...,"CallbackMetadata":{"Item":[{"Name":"Amount","Value":1.0}, ...]}}}}
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class StkCallbackPayload {
|
||||
|
||||
@JsonProperty("Body")
|
||||
private Body body;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Body {
|
||||
@JsonProperty("stkCallback")
|
||||
private StkCallbackBody stkCallback;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class StkCallbackBody {
|
||||
@JsonProperty("MerchantRequestID")
|
||||
private String merchantRequestId;
|
||||
|
||||
@JsonProperty("CheckoutRequestID")
|
||||
private String checkoutRequestId;
|
||||
|
||||
@JsonProperty("ResultCode")
|
||||
private Integer resultCode;
|
||||
|
||||
@JsonProperty("ResultDesc")
|
||||
private String resultDesc;
|
||||
|
||||
@JsonProperty("CallbackMetadata")
|
||||
private CallbackMetadata callbackMetadata;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class CallbackMetadata {
|
||||
@JsonProperty("Item")
|
||||
private List<Item> item;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Item {
|
||||
@JsonProperty("Name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("Value")
|
||||
private Object value;
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/test/payment/dto/StkQueryRequestDto.java
Normal file
24
src/main/java/com/test/payment/dto/StkQueryRequestDto.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class StkQueryRequestDto {
|
||||
|
||||
@JsonProperty("BusinessShortCode")
|
||||
private Long businessShortCode;
|
||||
|
||||
@JsonProperty("Password")
|
||||
private String password;
|
||||
|
||||
@JsonProperty("Timestamp")
|
||||
private String timestamp;
|
||||
|
||||
@JsonProperty("CheckoutRequestID")
|
||||
private String checkoutRequestId;
|
||||
}
|
||||
28
src/main/java/com/test/payment/dto/StkQueryResponseDto.java
Normal file
28
src/main/java/com/test/payment/dto/StkQueryResponseDto.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class StkQueryResponseDto {
|
||||
|
||||
@JsonProperty("ResponseCode")
|
||||
private String responseCode;
|
||||
|
||||
@JsonProperty("ResponseDescription")
|
||||
private String responseDescription;
|
||||
|
||||
@JsonProperty("MerchantRequestID")
|
||||
private String merchantRequestId;
|
||||
|
||||
@JsonProperty("CheckoutRequestID")
|
||||
private String checkoutRequestId;
|
||||
|
||||
@JsonProperty("ResultCode")
|
||||
private String resultCode;
|
||||
|
||||
@JsonProperty("ResultDesc")
|
||||
private String resultDesc;
|
||||
}
|
||||
30
src/main/java/com/test/payment/dto/TransactionStatusDto.java
Normal file
30
src/main/java/com/test/payment/dto/TransactionStatusDto.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class TransactionStatusDto {
|
||||
|
||||
private Long initiationId;
|
||||
private String provider;
|
||||
private String providerReference;
|
||||
private String secondaryReference;
|
||||
private String status;
|
||||
private String phoneNumber;
|
||||
private BigDecimal amount;
|
||||
private String accountReference;
|
||||
private String resultCode;
|
||||
private String resultDesc;
|
||||
private String receiptNumber;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
|
||||
public class MpesaBusyException extends RuntimeException {
|
||||
public MpesaBusyException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
|
||||
public class MpesaPermanentException extends RuntimeException {
|
||||
public MpesaPermanentException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
public class MpesaTransientException extends RuntimeException {
|
||||
public MpesaTransientException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
|
||||
public class ProviderBusyException extends RuntimeException {
|
||||
public ProviderBusyException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
|
||||
public class ProviderPermanentException extends RuntimeException {
|
||||
public ProviderPermanentException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
/**
|
||||
* The transaction is still being processed by M-Pesa (STK query errorCode 500.001.1001).
|
||||
* Not a failure — the transaction stays PENDING until the callback or a later status check.
|
||||
*/
|
||||
public class ProviderProcessingException extends RuntimeException {
|
||||
public ProviderProcessingException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
public class ProviderTransientException extends RuntimeException {
|
||||
public ProviderTransientException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.test.payment.jobs;
|
||||
|
||||
|
||||
import com.test.payment.repository.MpesaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaTransactionJob {
|
||||
|
||||
private final MpesaRepository mpesaRepository;
|
||||
|
||||
@Scheduled(fixedDelay = 60000)
|
||||
public void pullTransactions() {
|
||||
mpesaRepository.findAll()
|
||||
.doOnNext(tx -> log.info("Checking transaction: {}", tx))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.test.payment.jobs;
|
||||
|
||||
import com.test.payment.models.TransactionStatus;
|
||||
import com.test.payment.repository.PaymentInitiationRepository;
|
||||
import com.test.payment.service.PaymentLifecycleService;
|
||||
import com.test.payment.service.PaymentProviderService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.convert.DurationStyle;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Reconciles payments (any provider) stuck in PENDING longer than
|
||||
* payments.reconciliation.pending-age by querying the provider for the real
|
||||
* outcome and updating the database.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class PaymentReconciliationJob {
|
||||
|
||||
private final PaymentInitiationRepository initiationRepository;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final Environment environment;
|
||||
private final Map<String, PaymentProviderService> servicesByProvider;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
public PaymentReconciliationJob(PaymentInitiationRepository initiationRepository,
|
||||
PaymentLifecycleService lifecycle,
|
||||
Environment environment,
|
||||
List<PaymentProviderService> providerServices) {
|
||||
this.initiationRepository = initiationRepository;
|
||||
this.lifecycle = lifecycle;
|
||||
this.environment = environment;
|
||||
this.servicesByProvider = providerServices.stream()
|
||||
.collect(Collectors.toMap(PaymentProviderService::provider, Function.identity()));
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${payments.reconciliation.fixed-delay:60s}")
|
||||
public void reconcilePendingTransactions() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.debug("Previous reconciliation run still in progress — skipping this tick");
|
||||
return;
|
||||
}
|
||||
Duration pendingAge = DurationStyle.detectAndParse(
|
||||
environment.getProperty("payments.reconciliation.pending-age", "5m"));
|
||||
LocalDateTime cutoff = LocalDateTime.now().minus(pendingAge);
|
||||
|
||||
initiationRepository.findByStatusAndCreatedAtBefore(TransactionStatus.PENDING.name(), cutoff)
|
||||
.concatMap(initiation -> {
|
||||
PaymentProviderService service = servicesByProvider.get(initiation.getProvider());
|
||||
if (service == null) {
|
||||
log.error("No provider service registered for {} (initiation {})",
|
||||
initiation.getProvider(), initiation.getId());
|
||||
return Mono.empty();
|
||||
}
|
||||
return lifecycle.reconcile(initiation, service::checkStatus)
|
||||
.doOnSubscribe(s -> log.info("[{}] reconciling PENDING initiation {} (created {})",
|
||||
initiation.getProvider(), initiation.getId(), initiation.getCreatedAt()))
|
||||
.onErrorResume(e -> {
|
||||
log.error("[{}] reconciliation failed for initiation {}: {}",
|
||||
initiation.getProvider(), initiation.getId(), e.toString());
|
||||
return Mono.empty();
|
||||
});
|
||||
})
|
||||
.doFinally(signal -> running.set(false))
|
||||
.subscribe(
|
||||
v -> { },
|
||||
e -> log.error("Reconciliation run aborted: {}", e.toString()));
|
||||
}
|
||||
}
|
||||
100
src/main/java/com/test/payment/models/DatabaseSchema.java
Normal file
100
src/main/java/com/test/payment/models/DatabaseSchema.java
Normal file
@@ -0,0 +1,100 @@
|
||||
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<String> 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)
|
||||
)
|
||||
"""
|
||||
);
|
||||
}
|
||||
33
src/main/java/com/test/payment/models/PaymentCallback.java
Normal file
33
src/main/java/com/test/payment/models/PaymentCallback.java
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
30
src/main/java/com/test/payment/models/PaymentInitiation.java
Normal file
30
src/main/java/com/test/payment/models/PaymentInitiation.java
Normal file
@@ -0,0 +1,30 @@
|
||||
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_INITIATIONS")
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
public enum PaymentProviderType {
|
||||
MPESA,
|
||||
AIRTEL,
|
||||
MTN
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PaymentRequest {
|
||||
private long phoneNumber;
|
||||
private int amount;
|
||||
private String accountReference;
|
||||
private String transactionDesc;
|
||||
}
|
||||
35
src/main/java/com/test/payment/models/PaymentResponse.java
Normal file
35
src/main/java/com/test/payment/models/PaymentResponse.java
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
25
src/main/java/com/test/payment/models/ProviderToken.java
Normal file
25
src/main/java/com/test/payment/models/ProviderToken.java
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Table("PROVIDER_TOKENS")
|
||||
public class ProviderToken {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
private String provider;
|
||||
private String accessToken;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,23 +1,42 @@
|
||||
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.*;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
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
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Table("transactions")
|
||||
@Table("TRANSACTIONS")
|
||||
public class Transaction {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
private long phoneNumber;
|
||||
private long amount;
|
||||
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 checkoutRequestId;
|
||||
private String resultCode;
|
||||
private String resultDesc;
|
||||
private String receiptNumber;
|
||||
private String transactionDate;
|
||||
private String resolvedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
public enum TransactionStatus {
|
||||
PENDING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
|
||||
import com.test.payment.models.Transaction;
|
||||
//import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
@Repository
|
||||
public interface MpesaRepository extends ReactiveCrudRepository<Transaction, Long> {
|
||||
|
||||
/* public Mono<Void> saveTransaction(Transaction transaction) {
|
||||
return Mono.fromRunnable(() ->
|
||||
redisTemplate.opsForHash().put("mpesa:transactions", transaction.getId(), transaction.getStatus())
|
||||
).then();
|
||||
}
|
||||
|
||||
public Flux<Transaction> findAllTransactions() {
|
||||
List<Object> values = redisTemplate.opsForHash().values("mpesa:transactions");
|
||||
return Flux.fromIterable(values).cast(Transaction.class);
|
||||
}*/
|
||||
|
||||
Flux<Transaction> findByStatus(String status);
|
||||
Mono<Transaction> findByCheckoutRequestId(String checkoutRequestId);
|
||||
|
||||
/* private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
public MpesaRepository(RedisTemplate<String, String> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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<PaymentCallback, Long> {
|
||||
|
||||
Mono<PaymentCallback> findByInitiationId(Long initiationId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
import com.test.payment.models.PaymentInitiation;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public interface PaymentInitiationRepository extends ReactiveCrudRepository<PaymentInitiation, Long> {
|
||||
|
||||
Flux<PaymentInitiation> findByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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<PaymentResponse, Long> {
|
||||
|
||||
Mono<PaymentResponse> findByInitiationId(Long initiationId);
|
||||
|
||||
Mono<PaymentResponse> findByProviderReference(String providerReference);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
import com.test.payment.models.ProviderToken;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public interface ProviderTokenRepository extends ReactiveCrudRepository<ProviderToken, Long> {
|
||||
|
||||
Mono<ProviderToken> findFirstByProviderAndExpiresAtAfterOrderByIdDesc(String provider, LocalDateTime cutoff);
|
||||
|
||||
Mono<Void> deleteByProvider(String provider);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
import com.test.payment.models.Transaction;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface TransactionRepository extends ReactiveCrudRepository<Transaction, Long> {
|
||||
|
||||
Mono<Transaction> findByInitiationId(Long initiationId);
|
||||
|
||||
Flux<Transaction> findByProvider(String provider);
|
||||
|
||||
Flux<Transaction> findByStatus(String status);
|
||||
}
|
||||
138
src/main/java/com/test/payment/service/AirtelService.java
Normal file
138
src/main/java/com/test/payment/service/AirtelService.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.client.AirtelClient;
|
||||
import com.test.payment.dto.AirtelCallbackPayload;
|
||||
import com.test.payment.dto.AirtelPaymentRequestDto;
|
||||
import com.test.payment.dto.AirtelResponseDto;
|
||||
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.exceptions.ProviderBusyException;
|
||||
import com.test.payment.models.PaymentProviderType;
|
||||
import com.test.payment.models.PaymentResponse;
|
||||
import com.test.payment.models.TransactionStatus;
|
||||
import com.test.payment.service.PaymentLifecycleService.CallbackData;
|
||||
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
|
||||
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Airtel Money USSD-push collections. Transaction status codes:
|
||||
* TIP = in progress, TS = success, TF = failed.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AirtelService implements PaymentProviderService {
|
||||
|
||||
private final AirtelClient airtelClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public String provider() {
|
||||
return PaymentProviderType.AIRTEL.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
.flatMap(initiation -> Mono.defer(() -> {
|
||||
String reference = "ATL" + UUID.randomUUID().toString().replace("-", "");
|
||||
return airtelClient.pay(buildRequest(request, reference))
|
||||
.map(response -> toResponseData(response, reference));
|
||||
})
|
||||
.flatMap(data -> lifecycle.persistResponse(initiation, data))
|
||||
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
|
||||
}
|
||||
|
||||
public Mono<CallbackAckDto> handleCallback(AirtelCallbackPayload payload) {
|
||||
if (payload == null || payload.getTransaction() == null) {
|
||||
log.warn("Received malformed Airtel callback payload");
|
||||
return Mono.just(CallbackAckDto.accepted("Ignored: empty callback"));
|
||||
}
|
||||
AirtelCallbackPayload.Transaction transaction = payload.getTransaction();
|
||||
boolean success = "TS".equalsIgnoreCase(transaction.getStatusCode());
|
||||
CallbackData data = new CallbackData(
|
||||
transaction.getId(),
|
||||
transaction.getStatusCode(),
|
||||
transaction.getMessage(),
|
||||
transaction.getAirtelMoneyId(),
|
||||
null, null, null,
|
||||
success);
|
||||
return lifecycle.applyCallback(provider(), data, toJson(payload));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<TransactionStatusDto> checkStatus(String providerReference) {
|
||||
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
|
||||
}
|
||||
|
||||
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
|
||||
return airtelClient.status(response.getProviderReference())
|
||||
.map(result -> {
|
||||
AirtelResponseDto.Transaction tx = result.getData() != null ? result.getData().getTransaction() : null;
|
||||
String status = tx != null ? tx.getStatus() : null;
|
||||
String message = tx != null && tx.getMessage() != null
|
||||
? tx.getMessage()
|
||||
: (result.getStatus() != null ? result.getStatus().getMessage() : null);
|
||||
return new QueryOutcome(mapStatus(status), status, message,
|
||||
tx != null ? tx.getAirtelMoneyId() : null);
|
||||
})
|
||||
.onErrorResume(ProviderBusyException.class,
|
||||
e -> Mono.just(QueryOutcome.pending("Airtel status query rate-limited — showing last known state")));
|
||||
}
|
||||
|
||||
private AirtelPaymentRequestDto buildRequest(PaymentRequest request, String reference) {
|
||||
String country = environment.getProperty("airtel.country", "KE");
|
||||
String currency = environment.getProperty("airtel.currency", "KES");
|
||||
return new AirtelPaymentRequestDto(
|
||||
request.getAccountReference(),
|
||||
new AirtelPaymentRequestDto.Subscriber(country, currency, request.getPhoneNumber()),
|
||||
new AirtelPaymentRequestDto.Transaction(
|
||||
String.valueOf(request.getAmount()), country, currency, reference));
|
||||
}
|
||||
|
||||
private ProviderResponseData toResponseData(AirtelResponseDto response, String reference) {
|
||||
AirtelResponseDto.Status status = response.getStatus();
|
||||
boolean accepted = status != null && Boolean.TRUE.equals(status.getSuccess());
|
||||
String transactionStatus = response.getData() != null && response.getData().getTransaction() != null
|
||||
? response.getData().getTransaction().getStatus()
|
||||
: null;
|
||||
return new ProviderResponseData(
|
||||
reference,
|
||||
null,
|
||||
status != null ? status.getCode() : null,
|
||||
status != null ? status.getMessage() : null,
|
||||
transactionStatus,
|
||||
accepted);
|
||||
}
|
||||
|
||||
private TransactionStatus mapStatus(String airtelStatus) {
|
||||
if ("TS".equalsIgnoreCase(airtelStatus)) {
|
||||
return TransactionStatus.SUCCESS;
|
||||
}
|
||||
if ("TF".equalsIgnoreCase(airtelStatus)) {
|
||||
return TransactionStatus.FAILED;
|
||||
}
|
||||
return TransactionStatus.PENDING; // TIP or unknown — keep waiting
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not serialize callback payload: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.dto.OAuth2TokenResponse;
|
||||
import com.test.payment.models.PaymentProviderType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AirtelTokenService {
|
||||
|
||||
private final WebClient airtelWebClient;
|
||||
private final TokenCacheService tokenCache;
|
||||
private final Environment environment;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
return tokenCache.getToken(PaymentProviderType.AIRTEL.name(), this::fetchToken);
|
||||
}
|
||||
|
||||
public Mono<Void> evictToken() {
|
||||
return tokenCache.evictToken(PaymentProviderType.AIRTEL.name());
|
||||
}
|
||||
|
||||
private Mono<TokenCacheService.FetchedToken> fetchToken() {
|
||||
return airtelWebClient.post()
|
||||
.uri("/auth/oauth2/token")
|
||||
.bodyValue(Map.of(
|
||||
"client_id", environment.getProperty("airtel.client-id", ""),
|
||||
"client_secret", environment.getProperty("airtel.client-secret", ""),
|
||||
"grant_type", "client_credentials"))
|
||||
.retrieve()
|
||||
.bodyToMono(OAuth2TokenResponse.class)
|
||||
.map(resp -> new TokenCacheService.FetchedToken(resp.getAccessToken(), resp.expiresInSeconds()));
|
||||
}
|
||||
}
|
||||
@@ -1,204 +1,156 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import com.test.payment.client.MpesaClient;
|
||||
import com.test.payment.dto.CallbackAckDto;
|
||||
import com.test.payment.dto.MpesaRequestDto;
|
||||
import com.test.payment.dto.MpesaTokenResponse;
|
||||
import com.test.payment.exceptions.MpesaBusyException;
|
||||
import com.test.payment.exceptions.MpesaTransientException;
|
||||
import com.test.payment.models.MpesaResponse;
|
||||
import com.test.payment.models.PaymentRequest;
|
||||
import com.test.payment.repository.MpesaRepository;
|
||||
import com.test.payment.dto.MpesaResponse;
|
||||
import com.test.payment.dto.PaymentRequest;
|
||||
import com.test.payment.dto.PaymentResultDto;
|
||||
import com.test.payment.dto.StkCallbackPayload;
|
||||
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.PaymentProviderType;
|
||||
import com.test.payment.models.PaymentResponse;
|
||||
import com.test.payment.models.TransactionStatus;
|
||||
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.utils.MpesaUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaService {
|
||||
public class MpesaService implements PaymentProviderService {
|
||||
|
||||
private final WebClient mpesaWebClient;
|
||||
private final MpesaRepository mpesaRepository;
|
||||
private final MpesaClient mpesaClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
return Mono.just("hVG5ybD47dHUMQ6RfWby2ZpIs1Ul");
|
||||
@Override
|
||||
public String provider() {
|
||||
return PaymentProviderType.MPESA.name();
|
||||
}
|
||||
|
||||
@CircuitBreaker(name = "mpesaCircuitBreaker", fallbackMethod = "mpesaFallback")
|
||||
@RateLimiter(name = "mpesaLimiter")
|
||||
@Retry(name = "mpesaRetry")
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
String businessShortCode = environment.getProperty("mpesa.business-short-code");
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
.flatMap(initiation -> Mono.defer(() -> mpesaClient.stkPush(buildStkRequest(request)))
|
||||
.flatMap(response -> lifecycle.persistResponse(initiation, toResponseData(response)))
|
||||
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
|
||||
}
|
||||
|
||||
public Mono<CallbackAckDto> handleCallback(StkCallbackPayload payload) {
|
||||
if (payload == null || payload.getBody() == null || payload.getBody().getStkCallback() == null) {
|
||||
log.warn("Received malformed STK callback payload");
|
||||
return Mono.just(CallbackAckDto.accepted("Ignored: empty callback"));
|
||||
}
|
||||
StkCallbackPayload.StkCallbackBody callback = payload.getBody().getStkCallback();
|
||||
Map<String, Object> metadata = extractMetadata(callback);
|
||||
boolean success = callback.getResultCode() != null && callback.getResultCode() == 0;
|
||||
CallbackData data = new CallbackData(
|
||||
callback.getCheckoutRequestId(),
|
||||
callback.getResultCode() == null ? null : String.valueOf(callback.getResultCode()),
|
||||
callback.getResultDesc(),
|
||||
asString(metadata.get("MpesaReceiptNumber")),
|
||||
metadata.get("Amount") != null ? new BigDecimal(metadata.get("Amount").toString()) : null,
|
||||
asString(metadata.get("PhoneNumber")),
|
||||
asString(metadata.get("TransactionDate")),
|
||||
success);
|
||||
return lifecycle.applyCallback(provider(), data, toJson(payload));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<TransactionStatusDto> checkStatus(String providerReference) {
|
||||
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
|
||||
}
|
||||
|
||||
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
|
||||
String shortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callback = "https://mydomain.com/path";
|
||||
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
|
||||
StkQueryRequestDto query = new StkQueryRequestDto(
|
||||
Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.getProviderReference());
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
return mpesaClient.stkQuery(query)
|
||||
.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")))
|
||||
.onErrorResume(ProviderBusyException.class,
|
||||
e -> Mono.just(QueryOutcome.pending("M-Pesa status query rate-limited — showing last known state")));
|
||||
}
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
|
||||
Long.valueOf(businessShortCode),
|
||||
authData.getPassword(),
|
||||
authData.getTimestamp(),
|
||||
private MpesaRequestDto buildStkRequest(PaymentRequest request) {
|
||||
String shortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callbackUrl = environment.getProperty("mpesa.callback-url");
|
||||
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
|
||||
Long phone = Long.valueOf(request.getPhoneNumber());
|
||||
return new MpesaRequestDto(
|
||||
Long.valueOf(shortCode),
|
||||
auth.getPassword(),
|
||||
auth.getTimestamp(),
|
||||
"CustomerPayBillOnline",
|
||||
request.getAmount(),
|
||||
request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),
|
||||
request.getPhoneNumber(),
|
||||
callback,
|
||||
phone,
|
||||
Long.valueOf(shortCode),
|
||||
phone,
|
||||
callbackUrl,
|
||||
request.getAccountReference(),
|
||||
request.getTransactionDesc()
|
||||
);
|
||||
|
||||
// Use Mono.defer so each subscription is independent (multi-user safe)
|
||||
return Mono.defer(() ->
|
||||
getToken()
|
||||
.flatMap(token -> callMpesa(mpesaRequestDto))
|
||||
)
|
||||
// Reactive retry only on MpesaBusyException, with configurable backoff
|
||||
.retryWhen(
|
||||
reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(10))
|
||||
.filter(ex -> ex instanceof MpesaBusyException)
|
||||
.onRetryExhaustedThrow((spec, signal) -> signal.failure())
|
||||
)
|
||||
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
|
||||
.doOnError(e -> log.error("M-Pesa call failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/* @CircuitBreaker(name = "mpesaCircuitBreaker", fallbackMethod = "mpesaFallback")
|
||||
@RateLimiter(name = "mpesaLimiter")
|
||||
@Retry(name = "mpesaRetry")
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
String businessShortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callback = "https://mydomain.com/path";
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
|
||||
Long.valueOf(businessShortCode),
|
||||
authData.getPassword(),
|
||||
authData.getTimestamp(),
|
||||
"CustomerPayBillOnline",
|
||||
request.getAmount(),
|
||||
request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),
|
||||
request.getPhoneNumber(),
|
||||
callback,
|
||||
request.getAccountReference(),
|
||||
request.getTransactionDesc()
|
||||
);
|
||||
*//*return Mono.delay(Duration.ofSeconds(1))
|
||||
.then(callMpesa(mpesaRequestDto))
|
||||
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
|
||||
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));*//*
|
||||
|
||||
*//* return callMpesa(mpesaRequestDto)
|
||||
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
|
||||
.doOnError(e -> {
|
||||
if (e.toString().contains("System is busy")) {
|
||||
log.warn("M-Pesa system is busy");
|
||||
throe Mono.error(new MpesaBusyException("System busy"));
|
||||
} else {
|
||||
log.error("M-Pesa API failed: {}", e.getMessage());
|
||||
}
|
||||
});*//*
|
||||
|
||||
return getToken()
|
||||
.flatMap(token -> callMpesa(mpesaRequestDto))
|
||||
.retryWhen(
|
||||
reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(10))
|
||||
.filter(ex -> ex instanceof MpesaBusyException)
|
||||
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) ->
|
||||
retrySignal.failure()
|
||||
)
|
||||
) .doOnError(e -> log.error("M-Pesa call failed: {}", e.getMessage()));
|
||||
}*/
|
||||
|
||||
/*@Retry(name = "mpesaRetry", fallbackMethod = "mpesaFallback")
|
||||
@RateLimiter(name = "mpesaLimiter")
|
||||
@CircuitBreaker(name = "mpesaCB", fallbackMethod = "mpesaFallback")
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
|
||||
String businessShortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callback = "https://mydomain.com/path";
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
|
||||
Long.valueOf(businessShortCode),
|
||||
authData.getPassword(),
|
||||
authData.getTimestamp(),
|
||||
"CustomerPayBillOnline",
|
||||
request.getAmount(),
|
||||
request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),
|
||||
request.getPhoneNumber(),
|
||||
callback,
|
||||
request.getAccountReference(),
|
||||
request.getTransactionDesc()
|
||||
);
|
||||
|
||||
return mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.bodyValue(mpesaRequestDto)
|
||||
.retrieve()
|
||||
.bodyToMono(MpesaResponse.class)
|
||||
.flatMap(resp -> {
|
||||
if (resp.toString().contains("System is busy")) {
|
||||
return Mono.error(new MpesaBusyException("System busy"));
|
||||
}
|
||||
return Mono.just(resp);
|
||||
});
|
||||
}*/
|
||||
|
||||
|
||||
|
||||
private Mono<MpesaResponse> callMpesa(MpesaRequestDto request) {
|
||||
return getToken()
|
||||
.flatMap(token ->
|
||||
mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, clientResponse ->
|
||||
clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
log.error("M-Pesa returned {} with body: {}", clientResponse.statusCode(), errorBody);
|
||||
if (errorBody.contains("System is busy")) {
|
||||
return Mono.error(new MpesaBusyException("System busy"));
|
||||
}
|
||||
return Mono.error(new MpesaTransientException(errorBody, null));
|
||||
})
|
||||
)
|
||||
.bodyToMono(MpesaResponse.class)
|
||||
);
|
||||
private ProviderResponseData toResponseData(MpesaResponse response) {
|
||||
boolean accepted = "0".equals(response.getResponseCode());
|
||||
return new ProviderResponseData(
|
||||
response.getCheckoutRequestId(),
|
||||
response.getMerchantRequestID(),
|
||||
response.getResponseCode(),
|
||||
response.getResponseDescription(),
|
||||
response.getCustomerMessage(),
|
||||
accepted);
|
||||
}
|
||||
|
||||
// 🧯 Fallback if circuit is open or all retries fail
|
||||
private Mono<MpesaResponse> mpesaFallback(PaymentRequest request, Throwable ex) {
|
||||
log.error("⚠️ Mpesa fallback triggered: {}", ex.getMessage());
|
||||
return Mono.just(new MpesaResponse(
|
||||
"500",
|
||||
"Fallback triggered due to service unavailability",
|
||||
null,
|
||||
"",
|
||||
null
|
||||
));
|
||||
private TransactionStatus mapQueryResult(String resultCode) {
|
||||
if (resultCode == null) {
|
||||
return TransactionStatus.PENDING;
|
||||
}
|
||||
return "0".equals(resultCode) ? TransactionStatus.SUCCESS : TransactionStatus.FAILED;
|
||||
}
|
||||
|
||||
private Map<String, Object> extractMetadata(StkCallbackPayload.StkCallbackBody callback) {
|
||||
if (callback.getCallbackMetadata() == null || callback.getCallbackMetadata().getItem() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return callback.getCallbackMetadata().getItem().stream()
|
||||
.filter(item -> item.getName() != null && item.getValue() != null)
|
||||
.collect(Collectors.toMap(StkCallbackPayload.Item::getName, StkCallbackPayload.Item::getValue, (a, b) -> a));
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not serialize callback payload: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import com.test.payment.dto.MpesaRequestDto;
|
||||
import com.test.payment.exceptions.MpesaTransientException;
|
||||
import com.test.payment.models.*;
|
||||
import com.test.payment.repository.MpesaRepository;
|
||||
import com.test.payment.utils.MpesaUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/*@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j*/
|
||||
public class MpesaServiceaa {
|
||||
/*
|
||||
private final WebClient mpesaWebClient;
|
||||
private final MpesaRepository mpesaRepository;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final Retry retry;
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
private final Environment environment;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
*//* // 1️⃣ Check Redis cache first
|
||||
return redisTemplate.opsForValue().get("mpesa:token")
|
||||
.flatMap(cachedToken -> {
|
||||
if (cachedToken != null) {
|
||||
return Mono.just(cachedToken);
|
||||
}
|
||||
return fetchNewToken();
|
||||
})
|
||||
// if no cached token, fetch and cache new one
|
||||
.switchIfEmpty(fetchNewToken());*//*
|
||||
return Mono.fromSupplier(() -> "Q3HCot6dNLTpUtpitkr5Tatsa4KB");
|
||||
// return fetchNewToken();
|
||||
}
|
||||
|
||||
private Mono<String> fetchNewToken() {
|
||||
|
||||
String baseUrl = environment.getProperty("mpesa.base-url");
|
||||
String consumerKey = environment.getProperty("mpesa.consumer-key");
|
||||
String consumerSecret = environment.getProperty("mpesa.consumer-secret");
|
||||
|
||||
String credentials = consumerKey + ":" + consumerSecret;
|
||||
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
|
||||
|
||||
return mpesaWebClient.get()
|
||||
.uri(baseUrl + "/oauth/v1/generate?grant_type=client_credentials")
|
||||
.header("Authorization", "Basic " + encodedCredentials)
|
||||
.retrieve()
|
||||
.bodyToMono(MpesaTokenResponse.class)
|
||||
.map(MpesaTokenResponse::getAccessToken);
|
||||
*//*.flatMap(token ->
|
||||
redisTemplate.opsForValue()
|
||||
.set("mpesa:token", token, Duration.ofMinutes(50))
|
||||
.thenReturn(token)
|
||||
);*//*
|
||||
}
|
||||
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
|
||||
String businessShortCode = environment.getProperty("mpesa.business-short-code");
|
||||
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
|
||||
String callback = "https://mydomain.com/path";
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(Long.valueOf(businessShortCode),authData.getPassword(),authData.getTimestamp(),"CustomerPayBillOnline",request.getAmount(),request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),request.getPhoneNumber(),callback,request.getAccountReference(), request.getTransactionDesc());
|
||||
|
||||
Supplier<Mono<MpesaResponse>> decoratedSupplier =
|
||||
CircuitBreaker.decorateSupplier(circuitBreaker,
|
||||
RateLimiter.decorateSupplier(rateLimiter,
|
||||
Retry.decorateSupplier(retry, () -> callMpesa(mpesaRequestDto))
|
||||
)
|
||||
);
|
||||
|
||||
return Mono.defer(decoratedSupplier)
|
||||
*//* .flatMap(response -> {
|
||||
*//**//* Transaction tx = new Transaction(UUID.randomUUID().toString(),
|
||||
request.getPhoneNumber(), request.getAmount(), response.getResponseCode(), response.getCheckoutRequestId());
|
||||
return mpesaRepository.save(tx).thenReturn(response);*//**//*
|
||||
return response;
|
||||
})*//*
|
||||
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
private Mono<MpesaResponse> callMpesa(MpesaRequestDto request) {
|
||||
|
||||
getToken()
|
||||
.map(token -> {
|
||||
System.out.println("Token: " + token);
|
||||
return token;
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return getToken()
|
||||
.flatMap(token ->
|
||||
mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
// Intercept 4xx/5xx and extract actual body
|
||||
.onStatus(HttpStatusCode::isError, clientResponse ->
|
||||
clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
log.error("M-Pesa returned {} with body: {}", clientResponse.statusCode(), errorBody);
|
||||
// Return a Mono.error so onErrorResume below can handle it
|
||||
return Mono.error(new RuntimeException(errorBody));
|
||||
})
|
||||
)
|
||||
.bodyToMono(MpesaResponse.class)
|
||||
// Catch the above RuntimeException and return the error body as normal data
|
||||
.onErrorResume(RuntimeException.class, ex -> {
|
||||
log.error("Returning raw M-Pesa 500 error body: {}", ex.getMessage());
|
||||
return Mono.error(new MpesaTransientException(ex.getMessage(), ex)); // This is the actual 500 error body from M-Pesa
|
||||
})
|
||||
.doOnNext(response -> log.info("M-Pesa STK Response: {}", response)));
|
||||
|
||||
}*/
|
||||
|
||||
/* private final WebClient mpesaWebClient;
|
||||
private final MpesaRepository mpesaRepository;
|
||||
private final io.github.resilience4j.ratelimiter.RateLimiter rateLimiter;
|
||||
private final io.github.resilience4j.retry.Retry retry;
|
||||
private final io.github.resilience4j.circuitbreaker.CircuitBreaker circuitBreaker;
|
||||
private final Environment environment;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
return Mono.fromSupplier(() -> "Q3HCot6dNLTpUtpitkr5Tatsa4KB");
|
||||
}
|
||||
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
String businessShortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callback = "https://mydomain.com/path";
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
|
||||
Long.valueOf(businessShortCode),
|
||||
authData.getPassword(),
|
||||
authData.getTimestamp(),
|
||||
"CustomerPayBillOnline",
|
||||
request.getAmount(),
|
||||
request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),
|
||||
request.getPhoneNumber(),
|
||||
callback,
|
||||
request.getAccountReference(),
|
||||
request.getTransactionDesc()
|
||||
);
|
||||
|
||||
// Reactive chaining with operators (not Supplier)
|
||||
return callMpesa(mpesaRequestDto)
|
||||
.transformDeferred(ReactorRateLimiterOperator.of(rateLimiter))
|
||||
.transformDeferred(ReactorRetryOperator.of(retry))
|
||||
.transformDeferred(ReactorCircuitBreakerOperator.of(circuitBreaker))
|
||||
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
|
||||
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
private Mono<MpesaResponse> callMpesa(MpesaRequestDto request) {
|
||||
return getToken()
|
||||
.flatMap(token ->
|
||||
mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, clientResponse ->
|
||||
clientResponse.bodyToMono(String.class)
|
||||
.flatMap(errorBody -> {
|
||||
log.error("M-Pesa returned {} with body: {}", clientResponse.statusCode(), errorBody);
|
||||
return Mono.error(new MpesaTransientException(errorBody, null));
|
||||
})
|
||||
)
|
||||
.bodyToMono(MpesaResponse.class)
|
||||
);
|
||||
}*/
|
||||
}
|
||||
@@ -1,27 +1,40 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import com.test.payment.dto.MpesaTokenResponse;
|
||||
import com.test.payment.models.PaymentProviderType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
//import org.springframework.data.redis.core.RedisTemplate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaTokenService {
|
||||
|
||||
// private final RedisTemplate<String, String> redisTemplate;
|
||||
private final WebClient mpesaWebClient;
|
||||
private final TokenCacheService tokenCache;
|
||||
private final Environment environment;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
/* String cachedToken = redisTemplate.opsForValue().get("mpesa:token");
|
||||
if (cachedToken != null) {
|
||||
return Mono.just(cachedToken);
|
||||
}
|
||||
// Simulate token fetch from M-Pesa auth endpoint
|
||||
String newToken = "access_token_" + System.currentTimeMillis();
|
||||
redisTemplate.opsForValue().set("mpesa:token", newToken, Duration.ofMinutes(50));*/
|
||||
return Mono.just("newToken");
|
||||
return tokenCache.getToken(PaymentProviderType.MPESA.name(), this::fetchToken);
|
||||
}
|
||||
|
||||
public Mono<Void> evictToken() {
|
||||
return tokenCache.evictToken(PaymentProviderType.MPESA.name());
|
||||
}
|
||||
|
||||
private Mono<TokenCacheService.FetchedToken> fetchToken() {
|
||||
return mpesaWebClient.get()
|
||||
.uri("/oauth/v1/generate?grant_type=client_credentials")
|
||||
.headers(h -> h.setBasicAuth(
|
||||
environment.getProperty("mpesa.consumer-key", ""),
|
||||
environment.getProperty("mpesa.consumer-secret", "")))
|
||||
.retrieve()
|
||||
.bodyToMono(MpesaTokenResponse.class)
|
||||
.map(resp -> new TokenCacheService.FetchedToken(
|
||||
resp.getAccessToken(), Long.parseLong(resp.getExpiresIn().trim())));
|
||||
}
|
||||
}
|
||||
|
||||
124
src/main/java/com/test/payment/service/MtnService.java
Normal file
124
src/main/java/com/test/payment/service/MtnService.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.client.MtnClient;
|
||||
import com.test.payment.dto.CallbackAckDto;
|
||||
import com.test.payment.dto.MtnPayRequestDto;
|
||||
import com.test.payment.dto.MtnStatusResponseDto;
|
||||
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.PaymentProviderType;
|
||||
import com.test.payment.models.PaymentResponse;
|
||||
import com.test.payment.models.TransactionStatus;
|
||||
import com.test.payment.service.PaymentLifecycleService.CallbackData;
|
||||
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
|
||||
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* MTN MoMo Collections (request-to-pay). We generate the X-Reference-Id UUID and use
|
||||
* it as the provider reference; externalId is set to the same value so callbacks can
|
||||
* be correlated. Status values: PENDING | SUCCESSFUL | FAILED.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MtnService implements PaymentProviderService {
|
||||
|
||||
private final MtnClient mtnClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public String provider() {
|
||||
return PaymentProviderType.MTN.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
.flatMap(initiation -> Mono.defer(() -> {
|
||||
String reference = UUID.randomUUID().toString();
|
||||
// 202 Accepted, empty body — the reference is all we get back
|
||||
return mtnClient.requestToPay(reference, buildRequest(request, reference))
|
||||
.thenReturn(new ProviderResponseData(
|
||||
reference, null, "202", "Accepted", "Request to pay accepted", true));
|
||||
})
|
||||
.flatMap(data -> lifecycle.persistResponse(initiation, data))
|
||||
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
|
||||
}
|
||||
|
||||
public Mono<CallbackAckDto> handleCallback(MtnStatusResponseDto payload) {
|
||||
if (payload == null || (payload.getReferenceId() == null && payload.getExternalId() == null)) {
|
||||
log.warn("Received malformed MTN callback payload");
|
||||
return Mono.just(CallbackAckDto.accepted("Ignored: empty callback"));
|
||||
}
|
||||
String reference = payload.getReferenceId() != null ? payload.getReferenceId() : payload.getExternalId();
|
||||
boolean success = "SUCCESSFUL".equalsIgnoreCase(payload.getStatus());
|
||||
CallbackData data = new CallbackData(
|
||||
reference,
|
||||
payload.getStatus(),
|
||||
success ? "Payment successful" : payload.reasonText(),
|
||||
payload.getFinancialTransactionId(),
|
||||
payload.getAmount() != null ? new BigDecimal(payload.getAmount()) : null,
|
||||
payload.getPayer() != null ? payload.getPayer().getPartyId() : null,
|
||||
null,
|
||||
success);
|
||||
return lifecycle.applyCallback(provider(), data, toJson(payload));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<TransactionStatusDto> checkStatus(String providerReference) {
|
||||
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
|
||||
}
|
||||
|
||||
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
|
||||
return mtnClient.status(response.getProviderReference())
|
||||
.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")));
|
||||
}
|
||||
|
||||
private MtnPayRequestDto buildRequest(PaymentRequest request, String reference) {
|
||||
return new MtnPayRequestDto(
|
||||
String.valueOf(request.getAmount()),
|
||||
environment.getProperty("mtn.currency", "EUR"),
|
||||
reference,
|
||||
new MtnPayRequestDto.Payer("MSISDN", request.getPhoneNumber()),
|
||||
request.getTransactionDesc(),
|
||||
request.getAccountReference());
|
||||
}
|
||||
|
||||
private TransactionStatus mapStatus(String mtnStatus) {
|
||||
if ("SUCCESSFUL".equalsIgnoreCase(mtnStatus)) {
|
||||
return TransactionStatus.SUCCESS;
|
||||
}
|
||||
if ("FAILED".equalsIgnoreCase(mtnStatus)) {
|
||||
return TransactionStatus.FAILED;
|
||||
}
|
||||
return TransactionStatus.PENDING;
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not serialize callback payload: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/main/java/com/test/payment/service/MtnTokenService.java
Normal file
42
src/main/java/com/test/payment/service/MtnTokenService.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.dto.OAuth2TokenResponse;
|
||||
import com.test.payment.models.PaymentProviderType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MtnTokenService {
|
||||
|
||||
private final WebClient mtnWebClient;
|
||||
private final TokenCacheService tokenCache;
|
||||
private final Environment environment;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
return tokenCache.getToken(PaymentProviderType.MTN.name(), this::fetchToken);
|
||||
}
|
||||
|
||||
public Mono<Void> evictToken() {
|
||||
return tokenCache.evictToken(PaymentProviderType.MTN.name());
|
||||
}
|
||||
|
||||
private Mono<TokenCacheService.FetchedToken> fetchToken() {
|
||||
return mtnWebClient.post()
|
||||
.uri("/collection/token/")
|
||||
.headers(h -> {
|
||||
h.setBasicAuth(
|
||||
environment.getProperty("mtn.api-user", ""),
|
||||
environment.getProperty("mtn.api-key", ""));
|
||||
h.set("Ocp-Apim-Subscription-Key", environment.getProperty("mtn.subscription-key", ""));
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(OAuth2TokenResponse.class)
|
||||
.map(resp -> new TokenCacheService.FetchedToken(resp.getAccessToken(), resp.expiresInSeconds()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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.PaymentCallback;
|
||||
import com.test.payment.models.PaymentInitiation;
|
||||
import com.test.payment.models.PaymentResponse;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
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.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PaymentLifecycleService {
|
||||
|
||||
private final PaymentInitiationRepository initiationRepository;
|
||||
private final PaymentResponseRepository responseRepository;
|
||||
private final PaymentCallbackRepository callbackRepository;
|
||||
private final TransactionRepository transactionRepository;
|
||||
|
||||
/** The provider's answer to an initiation. */
|
||||
public record ProviderResponseData(String providerReference, String secondaryReference, String responseCode,
|
||||
String responseDescription, String customerMessage, boolean accepted) {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<PaymentInitiation> 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<PaymentResultDto> 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()));
|
||||
}
|
||||
|
||||
public Mono<PaymentResultDto> 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));
|
||||
}
|
||||
|
||||
public Mono<CallbackAckDto> applyCallback(String 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");
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transaction state from the database; if it is still PENDING, runs the
|
||||
* provider's live status query (querier) and applies the outcome.
|
||||
*/
|
||||
public Mono<TransactionStatusDto> checkStatus(String provider, String providerReference,
|
||||
Function<PaymentResponse, Mono<QueryOutcome>> 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by the reconciliation job for PENDING initiations older than the configured age.
|
||||
*/
|
||||
public Mono<Void> reconcile(PaymentInitiation initiation,
|
||||
Function<String, Mono<TransactionStatusDto>> 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);
|
||||
}))
|
||||
.then();
|
||||
}
|
||||
|
||||
public Flux<Transaction> listTransactions(String provider) {
|
||||
return provider == null ? transactionRepository.findAll() : transactionRepository.findByProvider(provider);
|
||||
}
|
||||
|
||||
private Mono<Transaction> failTerminal(PaymentInitiation initiation, String reason) {
|
||||
return updateStatus(initiation, TransactionStatus.FAILED.name())
|
||||
.flatMap(updated -> recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION"));
|
||||
}
|
||||
|
||||
private Mono<CallbackAckDto> 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<TransactionStatusDto> 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<TransactionStatusDto> 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<PaymentCallback> cb = tuple.getT1();
|
||||
Optional<Transaction> 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<PaymentInitiation> updateStatus(PaymentInitiation initiation, String 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_id (UNIQUE) so it can never duplicate;
|
||||
* a later, richer resolution (e.g. a callback after a query) updates the row.
|
||||
*/
|
||||
private Mono<Transaction> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.dto.PaymentRequest;
|
||||
import com.test.payment.dto.PaymentResultDto;
|
||||
import com.test.payment.dto.TransactionStatusDto;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* One implementation per provider (MpesaService, AirtelService, MtnService).
|
||||
* The reconciliation job dispatches PENDING initiations to the right provider
|
||||
* through this interface.
|
||||
*/
|
||||
public interface PaymentProviderService {
|
||||
|
||||
String provider();
|
||||
|
||||
Mono<PaymentResultDto> initiatePayment(PaymentRequest request);
|
||||
|
||||
Mono<TransactionStatusDto> checkStatus(String providerReference);
|
||||
}
|
||||
111
src/main/java/com/test/payment/service/TokenCacheService.java
Normal file
111
src/main/java/com/test/payment/service/TokenCacheService.java
Normal file
@@ -0,0 +1,111 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
import com.test.payment.models.ProviderToken;
|
||||
import com.test.payment.repository.ProviderTokenRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Tiered OAuth token cache shared by all providers: Redis first, then the database,
|
||||
* then a live fetch (persisted to both tiers on success). Redis being down never
|
||||
* fails a request — reads/writes time out and degrade to the next tier.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TokenCacheService {
|
||||
|
||||
private static final Duration REDIS_TIMEOUT = Duration.ofMillis(800);
|
||||
|
||||
private final ProviderTokenRepository tokenRepository;
|
||||
private final ReactiveStringRedisTemplate redisTemplate;
|
||||
private final Environment environment;
|
||||
|
||||
public record FetchedToken(String accessToken, long expiresInSeconds) {
|
||||
}
|
||||
|
||||
public Mono<String> getToken(String provider, Supplier<Mono<FetchedToken>> fetcher) {
|
||||
return fromRedis(provider)
|
||||
.switchIfEmpty(Mono.defer(() -> fromDatabase(provider)))
|
||||
.switchIfEmpty(Mono.defer(() -> fetchAndStore(provider, fetcher)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached token from Redis and the database (used on 401 from the provider).
|
||||
*/
|
||||
public Mono<Void> evictToken(String 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));
|
||||
}
|
||||
|
||||
private Mono<String> fromRedis(String provider) {
|
||||
return redisTemplate.opsForValue().get(redisKey(provider))
|
||||
.timeout(REDIS_TIMEOUT)
|
||||
.doOnNext(t -> log.debug("[{}] token served from Redis", provider))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[{}] Redis unavailable for token read ({}) — falling back to database", provider, e.toString());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> fromDatabase(String provider) {
|
||||
int buffer = expiryBufferSeconds();
|
||||
return tokenRepository.findFirstByProviderAndExpiresAtAfterOrderByIdDesc(provider, LocalDateTime.now().plusSeconds(buffer))
|
||||
.flatMap(token -> {
|
||||
long ttl = Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds() - buffer;
|
||||
log.debug("[{}] token served from database", provider);
|
||||
return cacheInRedis(provider, token.getAccessToken(), ttl).thenReturn(token.getAccessToken());
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> fetchAndStore(String provider, Supplier<Mono<FetchedToken>> fetcher) {
|
||||
return fetcher.get()
|
||||
.flatMap(fetched -> {
|
||||
ProviderToken token = ProviderToken.builder()
|
||||
.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)
|
||||
.then(cacheInRedis(provider, fetched.accessToken(), fetched.expiresInSeconds() - expiryBufferSeconds()))
|
||||
.thenReturn(fetched.accessToken());
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> cacheInRedis(String provider, String token, long ttlSeconds) {
|
||||
if (ttlSeconds <= 0) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return redisTemplate.opsForValue().set(redisKey(provider), token, Duration.ofSeconds(ttlSeconds))
|
||||
.timeout(REDIS_TIMEOUT)
|
||||
.doOnNext(ok -> log.debug("[{}] token cached in Redis for {}s", provider, ttlSeconds))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[{}] Redis unavailable for token write ({}) — token persisted in database only", provider, e.toString());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private String redisKey(String provider) {
|
||||
return provider.toLowerCase() + ":access_token";
|
||||
}
|
||||
|
||||
private int expiryBufferSeconds() {
|
||||
return environment.getProperty("payments.token-expiry-buffer-seconds", Integer.class, 60);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
package com.test.payment.utils;
|
||||
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class MpesaUtils {
|
||||
|
||||
private static final ZoneId NAIROBI = ZoneId.of("Africa/Nairobi");
|
||||
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
private MpesaUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a timestamp in the format yyyyMMddHHmmss
|
||||
* Generates a timestamp in the format yyyyMMddHHmmss (Africa/Nairobi time).
|
||||
* Example: 20251010162455
|
||||
*/
|
||||
public static String generateTimestamp() {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
|
||||
// Set timezone to Africa/Nairobi
|
||||
sdf.setTimeZone(TimeZone.getTimeZone("Africa/Nairobi"));
|
||||
return sdf.format(new Date());
|
||||
return ZonedDateTime.now(NAIROBI).format(TIMESTAMP_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ public class MpesaUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to generate both password and timestamp together.
|
||||
* Generates both password and timestamp together — always call this per attempt
|
||||
* so the password matches a fresh timestamp.
|
||||
*/
|
||||
public static MpesaAuthData generateAuthData(String businessShortCode, String passkey) {
|
||||
String timestamp = generateTimestamp();
|
||||
@@ -39,15 +41,7 @@ public class MpesaUtils {
|
||||
return new MpesaAuthData(password, timestamp);
|
||||
}
|
||||
|
||||
// Inner class to hold both password and timestamp
|
||||
public static class MpesaAuthData {
|
||||
private final String password;
|
||||
private final String timestamp;
|
||||
|
||||
public MpesaAuthData(String password, String timestamp) {
|
||||
this.password = password;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
public record MpesaAuthData(String password, String timestamp) {
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
|
||||
@@ -1,53 +1,61 @@
|
||||
#spring:
|
||||
# redis:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
|
||||
resilience4j:
|
||||
ratelimiter:
|
||||
instances:
|
||||
mpesaLimiter:
|
||||
limit-for-period: 10
|
||||
limit-for-period: 5
|
||||
limit-refresh-period: 1s
|
||||
timeout-duration: 0s
|
||||
timeout-duration: 5s
|
||||
airtelLimiter:
|
||||
limit-for-period: 5
|
||||
limit-refresh-period: 1s
|
||||
timeout-duration: 5s
|
||||
mtnLimiter:
|
||||
limit-for-period: 5
|
||||
limit-refresh-period: 1s
|
||||
timeout-duration: 5s
|
||||
|
||||
retry:
|
||||
instances:
|
||||
mpesaRetry:
|
||||
mpesaRetry: &provider-retry
|
||||
max-attempts: 3
|
||||
wait-duration: 2s
|
||||
retry-exceptions:
|
||||
- org.springframework.web.reactive.function.client.WebClientRequestException
|
||||
- java.io.IOException
|
||||
- com.example.mpesa.exceptions.MpesaBusyException
|
||||
- com.test.payment.exceptions.ProviderTransientException
|
||||
ignore-exceptions:
|
||||
- com.test.payment.exceptions.MpesaPermanentException
|
||||
- com.test.payment.exceptions.ProviderPermanentException
|
||||
- java.lang.IllegalArgumentException
|
||||
|
||||
airtelRetry: *provider-retry
|
||||
mtnRetry: *provider-retry
|
||||
|
||||
circuitbreaker:
|
||||
instances:
|
||||
mpesaCircuitBreaker:
|
||||
mpesaCircuitBreaker: &provider-circuit-breaker
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 10s
|
||||
|
||||
|
||||
|
||||
|
||||
ignore-exceptions:
|
||||
- com.test.payment.exceptions.ProviderPermanentException
|
||||
- com.test.payment.exceptions.ProviderProcessingException
|
||||
airtelCircuitBreaker: *provider-circuit-breaker
|
||||
mtnCircuitBreaker: *provider-circuit-breaker
|
||||
|
||||
spring:
|
||||
r2dbc:
|
||||
url: r2dbc:h2:mem:///mpesa_db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
username: sa
|
||||
password:
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations: classpath:schema.sql
|
||||
main:
|
||||
web-application-type: reactive
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
connect-timeout: 1s
|
||||
timeout: 1s
|
||||
|
||||
logging:
|
||||
level:
|
||||
@@ -62,9 +70,35 @@ springdoc:
|
||||
path: /v3/api-docs
|
||||
packages-to-scan: com.test.payment.controller
|
||||
|
||||
payments:
|
||||
token-expiry-buffer-seconds: 60
|
||||
reconciliation:
|
||||
# transactions still PENDING after this age are re-checked against the provider
|
||||
pending-age: 5m
|
||||
# how often the reconciliation job runs
|
||||
fixed-delay: 60s
|
||||
|
||||
mpesa:
|
||||
base-url: https://sandbox.safaricom.co.ke
|
||||
consumer-key: k6e7LtBNeVX7V8MPqB7P83FsZio8cRZD
|
||||
consumer-secret: cGwiWzhDGopC3dho
|
||||
business-short-code: 174379
|
||||
pass-key: bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
|
||||
callback-url: https://mydomain.com/api/mpesa/callback
|
||||
|
||||
airtel:
|
||||
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:
|
||||
base-url: https://sandbox.momodeveloper.mtn.com
|
||||
subscription-key: REPLACE_WITH_MTN_SUBSCRIPTION_KEY
|
||||
api-user: REPLACE_WITH_MTN_API_USER
|
||||
api-key: REPLACE_WITH_MTN_API_KEY
|
||||
target-environment: sandbox
|
||||
# MoMo sandbox only supports EUR
|
||||
currency: EUR
|
||||
callback-url: https://mydomain.com/api/mtn/callback
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mpesa_reference VARCHAR(255),
|
||||
checkout_request_id VARCHAR(255),
|
||||
status VARCHAR(50),
|
||||
amount DECIMAL(10,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
Reference in New Issue
Block a user