From e9d216be783cb9e2e485709b4bc6732264f01481 Mon Sep 17 00:00:00 2001 From: spiro-alvin-nyasimi Date: Thu, 2 Jul 2026 16:45:48 +0300 Subject: [PATCH] Updates and payments additions --- .gitignore | 65 +- .gradle/8.14.3/checksums/checksums.lock | Bin 17 -> 0 bytes .gradle/8.14.3/checksums/md5-checksums.bin | Bin 22797 -> 0 bytes .gradle/8.14.3/checksums/sha1-checksums.bin | Bin 27947 -> 0 bytes .../executionHistory/executionHistory.bin | Bin 376198 -> 0 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 0 bytes .gradle/8.14.3/fileChanges/last-build.bin | Bin 1 -> 0 bytes .gradle/8.14.3/fileHashes/fileHashes.bin | Bin 21147 -> 0 bytes .gradle/8.14.3/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../8.14.3/fileHashes/resourceHashesCache.bin | Bin 21387 -> 0 bytes .gradle/8.14.3/gc.properties | 0 .../buildOutputCleanup.lock | Bin 17 -> 0 bytes .gradle/buildOutputCleanup/cache.properties | 2 - .gradle/buildOutputCleanup/outputFiles.bin | Bin 18911 -> 0 bytes .gradle/file-system.probe | Bin 8 -> 0 bytes .gradle/vcs-1/gc.properties | 0 .idea/.gitignore | 5 - .idea/.name | 1 - .idea/compiler.xml | 18 - .idea/gradle.xml | 15 - .idea/misc.xml | 5 - .idea/modules.xml | 8 - .idea/modules/payment.main.iml | 8 - .idea/vcs.xml | 6 - CLAUDE.md | 75 ++ build.gradle | 24 +- build/reports/problems/problems-report.html | 663 ------------------ build/resources/main/application.yml | 70 -- build/resources/main/schema.sql | 8 - .../stash-dir/MpesaController.class.uniqueId1 | Bin 2958 -> 0 bytes .../stash-dir/MpesaService.class.uniqueId0 | Bin 10813 -> 0 bytes .../compileJava/previous-compilation-data.bin | Bin 56250 -> 0 bytes gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../com/test/payment/client/AirtelClient.java | 69 ++ .../com/test/payment/client/MpesaClient.java | 82 +++ .../com/test/payment/client/MtnClient.java | 80 +++ .../payment/client/ProviderHttpErrors.java | 55 ++ .../DatabaseSchemaInitializer.java | 26 + .../payment/configurations/RedisConfig.java | 22 - .../configurations/ResilienceConfig.java | 45 -- .../configurations/WebClientConfig.java | 28 +- .../payment/controller/AirtelController.java | 40 ++ .../controller/GlobalExceptionHandler.java | 90 +++ .../payment/controller/MpesaController.java | 40 +- .../payment/controller/MtnController.java | 50 ++ .../controller/PaymentsController.java | 26 + .../payment/dto/AirtelCallbackPayload.java | 29 + .../payment/dto/AirtelPaymentRequestDto.java | 37 + .../test/payment/dto/AirtelResponseDto.java | 45 ++ .../com/test/payment/dto/CallbackAckDto.java | 25 + .../test/payment/dto/ErrorResponseDto.java | 24 + .../{models => dto}/MpesaResponse.java | 2 +- .../test/payment/dto/MtnPayRequestDto.java | 29 + .../payment/dto/MtnStatusResponseDto.java | 27 + .../test/payment/dto/OAuth2TokenResponse.java | 28 + .../com/test/payment/dto/PaymentRequest.java | 28 + .../test/payment/dto/PaymentResultDto.java | 22 + .../test/payment/dto/StkCallbackPayload.java | 63 ++ .../test/payment/dto/StkQueryRequestDto.java | 24 + .../test/payment/dto/StkQueryResponseDto.java | 28 + .../payment/dto/TransactionStatusDto.java | 30 + .../exceptions/MpesaBusyException.java | 8 - .../exceptions/MpesaPermanentException.java | 8 - .../exceptions/MpesaTransientException.java | 7 - .../exceptions/ProviderBusyException.java | 8 + .../ProviderPermanentException.java | 8 + .../ProviderProcessingException.java | 11 + .../ProviderTransientException.java | 7 + .../payment/jobs/MpesaTransactionJob.java | 23 - .../jobs/PaymentReconciliationJob.java | 80 +++ .../test/payment/models/DatabaseSchema.java | 100 +++ .../test/payment/models/PaymentCallback.java | 33 + .../payment/models/PaymentInitiation.java | 30 + .../payment/models/PaymentProviderType.java | 7 + .../test/payment/models/PaymentRequest.java | 12 - .../test/payment/models/PaymentResponse.java | 35 + .../test/payment/models/ProviderToken.java | 25 + .../com/test/payment/models/Transaction.java | 35 +- .../payment/models/TransactionStatus.java | 7 + .../payment/repository/MpesaRepository.java | 39 -- .../repository/PaymentCallbackRepository.java | 12 + .../PaymentInitiationRepository.java | 14 + .../repository/PaymentResponseRepository.java | 14 + .../repository/ProviderTokenRepository.java | 16 + .../repository/TransactionRepository.java | 17 + .../test/payment/service/AirtelService.java | 138 ++++ .../payment/service/AirtelTokenService.java | 42 ++ .../test/payment/service/MpesaService.java | 294 ++++---- .../test/payment/service/MpesaServiceaa.java | 190 ----- .../payment/service/MpesaTokenService.java | 39 +- .../com/test/payment/service/MtnService.java | 124 ++++ .../test/payment/service/MtnTokenService.java | 42 ++ .../service/PaymentLifecycleService.java | 327 +++++++++ .../service/PaymentProviderService.java | 20 + .../payment/service/TokenCacheService.java | 111 +++ .../com/test/payment/utils/MpesaUtils.java | 34 +- src/main/resources/application.yml | 76 +- src/main/resources/schema.sql | 8 - 98 files changed, 2623 insertions(+), 1445 deletions(-) delete mode 100644 .gradle/8.14.3/checksums/checksums.lock delete mode 100644 .gradle/8.14.3/checksums/md5-checksums.bin delete mode 100644 .gradle/8.14.3/checksums/sha1-checksums.bin delete mode 100644 .gradle/8.14.3/executionHistory/executionHistory.bin delete mode 100644 .gradle/8.14.3/executionHistory/executionHistory.lock delete mode 100644 .gradle/8.14.3/fileChanges/last-build.bin delete mode 100644 .gradle/8.14.3/fileHashes/fileHashes.bin delete mode 100644 .gradle/8.14.3/fileHashes/fileHashes.lock delete mode 100644 .gradle/8.14.3/fileHashes/resourceHashesCache.bin delete mode 100644 .gradle/8.14.3/gc.properties delete mode 100644 .gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 .gradle/buildOutputCleanup/cache.properties delete mode 100644 .gradle/buildOutputCleanup/outputFiles.bin delete mode 100644 .gradle/file-system.probe delete mode 100644 .gradle/vcs-1/gc.properties delete mode 100644 .idea/.gitignore delete mode 100644 .idea/.name delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/modules/payment.main.iml delete mode 100644 .idea/vcs.xml create mode 100644 CLAUDE.md delete mode 100644 build/reports/problems/problems-report.html delete mode 100644 build/resources/main/application.yml delete mode 100644 build/resources/main/schema.sql delete mode 100644 build/tmp/compileJava/compileTransaction/stash-dir/MpesaController.class.uniqueId1 delete mode 100644 build/tmp/compileJava/compileTransaction/stash-dir/MpesaService.class.uniqueId0 delete mode 100644 build/tmp/compileJava/previous-compilation-data.bin create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 src/main/java/com/test/payment/client/AirtelClient.java create mode 100644 src/main/java/com/test/payment/client/MpesaClient.java create mode 100644 src/main/java/com/test/payment/client/MtnClient.java create mode 100644 src/main/java/com/test/payment/client/ProviderHttpErrors.java create mode 100644 src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java delete mode 100644 src/main/java/com/test/payment/configurations/RedisConfig.java delete mode 100644 src/main/java/com/test/payment/configurations/ResilienceConfig.java create mode 100644 src/main/java/com/test/payment/controller/AirtelController.java create mode 100644 src/main/java/com/test/payment/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/com/test/payment/controller/MtnController.java create mode 100644 src/main/java/com/test/payment/controller/PaymentsController.java create mode 100644 src/main/java/com/test/payment/dto/AirtelCallbackPayload.java create mode 100644 src/main/java/com/test/payment/dto/AirtelPaymentRequestDto.java create mode 100644 src/main/java/com/test/payment/dto/AirtelResponseDto.java create mode 100644 src/main/java/com/test/payment/dto/CallbackAckDto.java create mode 100644 src/main/java/com/test/payment/dto/ErrorResponseDto.java rename src/main/java/com/test/payment/{models => dto}/MpesaResponse.java (94%) create mode 100644 src/main/java/com/test/payment/dto/MtnPayRequestDto.java create mode 100644 src/main/java/com/test/payment/dto/MtnStatusResponseDto.java create mode 100644 src/main/java/com/test/payment/dto/OAuth2TokenResponse.java create mode 100644 src/main/java/com/test/payment/dto/PaymentRequest.java create mode 100644 src/main/java/com/test/payment/dto/PaymentResultDto.java create mode 100644 src/main/java/com/test/payment/dto/StkCallbackPayload.java create mode 100644 src/main/java/com/test/payment/dto/StkQueryRequestDto.java create mode 100644 src/main/java/com/test/payment/dto/StkQueryResponseDto.java create mode 100644 src/main/java/com/test/payment/dto/TransactionStatusDto.java delete mode 100644 src/main/java/com/test/payment/exceptions/MpesaBusyException.java delete mode 100644 src/main/java/com/test/payment/exceptions/MpesaPermanentException.java delete mode 100644 src/main/java/com/test/payment/exceptions/MpesaTransientException.java create mode 100644 src/main/java/com/test/payment/exceptions/ProviderBusyException.java create mode 100644 src/main/java/com/test/payment/exceptions/ProviderPermanentException.java create mode 100644 src/main/java/com/test/payment/exceptions/ProviderProcessingException.java create mode 100644 src/main/java/com/test/payment/exceptions/ProviderTransientException.java delete mode 100644 src/main/java/com/test/payment/jobs/MpesaTransactionJob.java create mode 100644 src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java create mode 100644 src/main/java/com/test/payment/models/DatabaseSchema.java create mode 100644 src/main/java/com/test/payment/models/PaymentCallback.java create mode 100644 src/main/java/com/test/payment/models/PaymentInitiation.java create mode 100644 src/main/java/com/test/payment/models/PaymentProviderType.java delete mode 100644 src/main/java/com/test/payment/models/PaymentRequest.java create mode 100644 src/main/java/com/test/payment/models/PaymentResponse.java create mode 100644 src/main/java/com/test/payment/models/ProviderToken.java create mode 100644 src/main/java/com/test/payment/models/TransactionStatus.java delete mode 100644 src/main/java/com/test/payment/repository/MpesaRepository.java create mode 100644 src/main/java/com/test/payment/repository/PaymentCallbackRepository.java create mode 100644 src/main/java/com/test/payment/repository/PaymentInitiationRepository.java create mode 100644 src/main/java/com/test/payment/repository/PaymentResponseRepository.java create mode 100644 src/main/java/com/test/payment/repository/ProviderTokenRepository.java create mode 100644 src/main/java/com/test/payment/repository/TransactionRepository.java create mode 100644 src/main/java/com/test/payment/service/AirtelService.java create mode 100644 src/main/java/com/test/payment/service/AirtelTokenService.java delete mode 100644 src/main/java/com/test/payment/service/MpesaServiceaa.java create mode 100644 src/main/java/com/test/payment/service/MtnService.java create mode 100644 src/main/java/com/test/payment/service/MtnTokenService.java create mode 100644 src/main/java/com/test/payment/service/PaymentLifecycleService.java create mode 100644 src/main/java/com/test/payment/service/PaymentProviderService.java create mode 100644 src/main/java/com/test/payment/service/TokenCacheService.java delete mode 100644 src/main/resources/schema.sql diff --git a/.gitignore b/.gitignore index 524f096..ba3cb82 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.gradle/8.14.3/checksums/checksums.lock b/.gradle/8.14.3/checksums/checksums.lock deleted file mode 100644 index 9fd90df9137c28fdf58f36557153764d88523d75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 VcmZRsIeswuP+gQg0~jz00{}NH1W*6~ diff --git a/.gradle/8.14.3/checksums/md5-checksums.bin b/.gradle/8.14.3/checksums/md5-checksums.bin deleted file mode 100644 index 85c3bc56872679bf9283ec2f779f0336869aec16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22797 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 CFp3%g diff --git a/.gradle/8.14.3/checksums/sha1-checksums.bin b/.gradle/8.14.3/checksums/sha1-checksums.bin deleted file mode 100644 index 028991a6ebb5494f688b52b4492e179b7b1a52b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27947 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 diff --git a/.gradle/8.14.3/executionHistory/executionHistory.bin b/.gradle/8.14.3/executionHistory/executionHistory.bin deleted file mode 100644 index 54e6c4a80693930531009d9d6bc29868ad3bde09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 376198 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 diff --git a/.gradle/8.14.3/executionHistory/executionHistory.lock b/.gradle/8.14.3/executionHistory/executionHistory.lock deleted file mode 100644 index 927f37c717aead8f322c8e68a43ff1ec0b28b4a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZRc8N2NI)pZV03=psa06Pu^JOBUy diff --git a/.gradle/8.14.3/fileChanges/last-build.bin b/.gradle/8.14.3/fileChanges/last-build.bin deleted file mode 100644 index f76dd238ade08917e6712764a16a22005a50573d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1 IcmZPo000310RR91 diff --git a/.gradle/8.14.3/fileHashes/fileHashes.bin b/.gradle/8.14.3/fileHashes/fileHashes.bin deleted file mode 100644 index d312e43ba1c0c72653c554c10b0902c9cf9e97f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21147 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 diff --git a/.gradle/8.14.3/gc.properties b/.gradle/8.14.3/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index 8a3c9b34d189381e337c7aba8d7935263905f956..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZSP@V(6{?6^&f0SuUe03ICz!2kdN diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index c17daad..0000000 --- a/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Fri Oct 10 12:56:24 EAT 2025 -gradle.version=8.14.3 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin deleted file mode 100644 index bb3069cde4d6047d4838dec954408282d6d5dcb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18911 zcmeI%K}b_^9Ki9vc93ecEW$~LNDL1Rik6C~FlWv!S_x8U>S5(f$V-HJ$b*qWrJ@j~ z5!g+T8WJTa7U9M0kf1OjkU*O0L;|y*n>~5IpVBpqBEApa_UHS4@4xpx*PSuOwSPkn zvwa-eUa~>}0R#|0009ILKmY**5I_I{1Q0*~0R#|00D=ES;G*>+d+e8Sxsx{+&Sqmy zI;=%n$DQ^U2WpRMEZB_QxyfF)7>i_vQOVuAN!nL zdfS^RJw^L;$ylR5(?1|RUHiwJ(TARIX-?VyvG(}VZ&hQ^U`l>Kl1j+YTcF8`?61w_S_WrWM#Z8B=g6$`v{+RN5@~ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe deleted file mode 100644 index 0d90605c06c560f3f258274728dbb3f540f859ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8 PcmZQzV4Qhw)e&U?3G4#; diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index a0ccf77..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Environment-dependent path to Maven home directory -/mavenHomeManager.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 4d4d2ce..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -payment \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 625c380..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index f9163b4..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 4ba5914..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index d28af19..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/payment.main.iml b/.idea/modules/payment.main.iml deleted file mode 100644 index afc1871..0000000 --- a/.idea/modules/payment.main.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ee45d9c --- /dev/null +++ b/CLAUDE.md @@ -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):** +`Controller` → `Service` (implements `PaymentProviderService`; parses provider payloads) → `PaymentLifecycleService` (ALL persistence: initiation/response/callback/transaction, dedup, status transitions) and `Client` (resilience-wrapped HTTP) → provider API. + +**Endpoints** (identical pattern per provider under `/api/mpesa`, `/api/airtel`, `/api/mtn`): +- `POST /pay` — validated request; persists an initiation, calls the provider, persists the linked response, returns the outcome with a `providerReference`. +- `POST /callback` — provider result callback (MTN also accepts PUT); stored 1:1 with the initiation, deduplicated, updates status. Always acks. +- `GET /status/{providerReference}` — DB state; if still PENDING, performs a live provider status query and updates the DB. Degrades to last-known state when the provider rate-limits. +- `GET /api/payments/transactions?provider=` — consolidated transactions across providers. + +**Provider specifics:** +- M-Pesa: `providerReference` = CheckoutRequestID, `secondaryReference` = MerchantRequestID; STK query "still processing" (errorCode 500.001.1001) maps to `ProviderProcessingException` → stays PENDING. Sandbox creds in yml are live. +- Airtel: reference is a generated `ATL` transaction id; status codes TIP (pending) / TS (success) / TF (failed); receipt = `airtel_money_id`. Credentials are placeholders (`airtel.*`). +- MTN MoMo: reference is the generated `X-Reference-Id` UUID (externalId is set to the same value for callback correlation); request-to-pay returns 202 with no body; status SUCCESSFUL/FAILED/PENDING; receipt = `financialTransactionId`. Sandbox needs `mtn.subscription-key`, `mtn.api-user`, `mtn.api-key`; sandbox currency is EUR. Placeholders in yml. + +**Key design points:** + +- All I/O is non-blocking (`Mono`/`Flux` throughout). Never block a reactive pipeline with `.block()` (startup schema init is the one deliberate exception). +- All outbound provider calls live in `client/*Client` classes so the Resilience4j annotations (`@CircuitBreaker`, `@RateLimiter`, `@Retry`) go through Spring AOP — they are silently skipped on self-invocation. Each provider has its own instances (`mpesa*`, `airtel*`, `mtn*`) configured **only** in `application.yml` (YAML anchors share the retry/circuit-breaker settings). +- Shared exception model in `exceptions/`: `ProviderBusyException` (429/busy → reactive backoff retry, 503), `ProviderTransientException` (Resilience4j retry, 502), `ProviderPermanentException` (never retried, CB-ignored, 400), `ProviderProcessingException` (still processing → stays PENDING). HTTP-to-exception mapping is shared in `client/ProviderHttpErrors` (401 evicts the token so the retry refetches). +- `TokenCacheService` is the tiered token cache for all providers: Redis (`:access_token`) → `PROVIDER_TOKENS` table → live OAuth fetch (persisted to both tiers). Redis being down never fails a request (800ms timeouts, falls through). `TokenService` classes only supply the fetch call. +- Boot 4 defaults to Jackson 3 (`tools.jackson.databind.ObjectMapper`) — inject that type, not `com.fasterxml`. The `com.fasterxml.jackson.annotation.*` annotations still work. +- Resilience4j 2.4.0 with `resilience4j-spring-boot4`; annotations require `aspectjweaver` (Boot 4 removed `spring-boot-starter-aop`). Boot 4 also split `WebClient` auto-config into `spring-boot-starter-webclient`. +- Package convention: `models` holds only database entities (`@Table`) plus `DatabaseSchema`; everything crossing an API boundary lives in `dto`. + +**Persistence (in-memory H2 via R2DBC):** +- The schema lives in code: `models/DatabaseSchema.STATEMENTS`, executed at startup by `configurations/DatabaseSchemaInitializer` (there is no schema.sql; `spring.sql.init` is not used). +- `PAYMENT_INITIATIONS` — one row per payment attempt, any provider (status: PENDING → SUCCESS/FAILED). +- `PAYMENT_RESPONSES` — the provider's answer, `initiation_id UNIQUE` (1:1) and `provider_reference UNIQUE` (lookup key for callbacks/status). +- `PAYMENT_CALLBACKS` — the provider result callback, `initiation_id UNIQUE`, duplicates ignored, raw payload stored as JSON. +- `TRANSACTIONS` — consolidated record upserted by `PaymentLifecycleService.recordTransaction` whenever an initiation reaches a terminal state, from whichever path resolved it (`resolvedBy`: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION). `initiation_id UNIQUE`. +- `PROVIDER_TOKENS` — OAuth tokens per provider with expiry. +- `@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. diff --git a/build.gradle b/build.gradle index 3dc7c69..b4d941f 100644 --- a/build.gradle +++ b/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") } diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html deleted file mode 100644 index 200197d..0000000 --- a/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -

- -
- Loading... -
- - - - - - diff --git a/build/resources/main/application.yml b/build/resources/main/application.yml deleted file mode 100644 index 8d45436..0000000 --- a/build/resources/main/application.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/build/resources/main/schema.sql b/build/resources/main/schema.sql deleted file mode 100644 index 9930216..0000000 --- a/build/resources/main/schema.sql +++ /dev/null @@ -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 -); diff --git a/build/tmp/compileJava/compileTransaction/stash-dir/MpesaController.class.uniqueId1 b/build/tmp/compileJava/compileTransaction/stash-dir/MpesaController.class.uniqueId1 deleted file mode 100644 index 521c44fd3106503aadb2ee2c6444b3a752d8e52e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2958 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 pCOp4G$eqJB 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 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 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)); + } +} diff --git a/src/main/java/com/test/payment/client/MpesaClient.java b/src/main/java/com/test/payment/client/MpesaClient.java new file mode 100644 index 0000000..933a145 --- /dev/null +++ b/src/main/java/com/test/payment/client/MpesaClient.java @@ -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 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 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 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()); + } +} diff --git a/src/main/java/com/test/payment/client/MtnClient.java b/src/main/java/com/test/payment/client/MtnClient.java new file mode 100644 index 0000000..117186f --- /dev/null +++ b/src/main/java/com/test/payment/client/MtnClient.java @@ -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 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 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", "")); + } +} diff --git a/src/main/java/com/test/payment/client/ProviderHttpErrors.java b/src/main/java/com/test/payment/client/ProviderHttpErrors.java new file mode 100644 index 0000000..b3840d0 --- /dev/null +++ b/src/main/java/com/test/payment/client/ProviderHttpErrors.java @@ -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 map(ClientResponse response, String provider, Mono 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 mapBody(HttpStatusCode status, String body, String provider, Mono 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)); + } +} diff --git a/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java b/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java new file mode 100644 index 0000000..2e124c6 --- /dev/null +++ b/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java @@ -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(); + } +} diff --git a/src/main/java/com/test/payment/configurations/RedisConfig.java b/src/main/java/com/test/payment/configurations/RedisConfig.java deleted file mode 100644 index 0bed43b..0000000 --- a/src/main/java/com/test/payment/configurations/RedisConfig.java +++ /dev/null @@ -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 redisTemplate(LettuceConnectionFactory factory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(factory); - return template; - }*/ -} diff --git a/src/main/java/com/test/payment/configurations/ResilienceConfig.java b/src/main/java/com/test/payment/configurations/ResilienceConfig.java deleted file mode 100644 index d4b9f43..0000000 --- a/src/main/java/com/test/payment/configurations/ResilienceConfig.java +++ /dev/null @@ -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()); - } -} diff --git a/src/main/java/com/test/payment/configurations/WebClientConfig.java b/src/main/java/com/test/payment/configurations/WebClientConfig.java index db8a9c8..0af94bf 100644 --- a/src/main/java/com/test/payment/configurations/WebClientConfig.java +++ b/src/main/java/com/test/payment/configurations/WebClientConfig.java @@ -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(); } } diff --git a/src/main/java/com/test/payment/controller/AirtelController.java b/src/main/java/com/test/payment/controller/AirtelController.java new file mode 100644 index 0000000..38eb885 --- /dev/null +++ b/src/main/java/com/test/payment/controller/AirtelController.java @@ -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 pay(@Valid @RequestBody PaymentRequest request) { + return airtelService.initiatePayment(request); + } + + @PostMapping("/callback") + public Mono callback(@RequestBody AirtelCallbackPayload payload) { + return airtelService.handleCallback(payload); + } + + @GetMapping("/status/{transactionId}") + public Mono status(@PathVariable String transactionId) { + return airtelService.checkStatus(transactionId); + } +} diff --git a/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java b/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..2e1d0cb --- /dev/null +++ b/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java @@ -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 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 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"); + } +} diff --git a/src/main/java/com/test/payment/controller/MpesaController.java b/src/main/java/com/test/payment/controller/MpesaController.java index 4287e86..27f5ec3 100644 --- a/src/main/java/com/test/payment/controller/MpesaController.java +++ b/src/main/java/com/test/payment/controller/MpesaController.java @@ -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> 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 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 callback(@RequestBody StkCallbackPayload payload) { + return mpesaService.handleCallback(payload); + } + + @GetMapping("/status/{checkoutRequestId}") + public Mono status(@PathVariable String checkoutRequestId) { + return mpesaService.checkStatus(checkoutRequestId); } } - diff --git a/src/main/java/com/test/payment/controller/MtnController.java b/src/main/java/com/test/payment/controller/MtnController.java new file mode 100644 index 0000000..12e108e --- /dev/null +++ b/src/main/java/com/test/payment/controller/MtnController.java @@ -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 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 callbackPost(@RequestBody MtnStatusResponseDto payload) { + return mtnService.handleCallback(payload); + } + + @PutMapping("/callback") + public Mono callbackPut(@RequestBody MtnStatusResponseDto payload) { + return mtnService.handleCallback(payload); + } + + @GetMapping("/status/{referenceId}") + public Mono status(@PathVariable String referenceId) { + return mtnService.checkStatus(referenceId); + } +} diff --git a/src/main/java/com/test/payment/controller/PaymentsController.java b/src/main/java/com/test/payment/controller/PaymentsController.java new file mode 100644 index 0000000..b783bc5 --- /dev/null +++ b/src/main/java/com/test/payment/controller/PaymentsController.java @@ -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 transactions(@RequestParam(required = false) String provider) { + return lifecycle.listTransactions(provider == null ? null : provider.toUpperCase()); + } +} diff --git a/src/main/java/com/test/payment/dto/AirtelCallbackPayload.java b/src/main/java/com/test/payment/dto/AirtelCallbackPayload.java new file mode 100644 index 0000000..aaed1e2 --- /dev/null +++ b/src/main/java/com/test/payment/dto/AirtelCallbackPayload.java @@ -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":"","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; + } +} diff --git a/src/main/java/com/test/payment/dto/AirtelPaymentRequestDto.java b/src/main/java/com/test/payment/dto/AirtelPaymentRequestDto.java new file mode 100644 index 0000000..3ddcdeb --- /dev/null +++ b/src/main/java/com/test/payment/dto/AirtelPaymentRequestDto.java @@ -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; + } +} diff --git a/src/main/java/com/test/payment/dto/AirtelResponseDto.java b/src/main/java/com/test/payment/dto/AirtelResponseDto.java new file mode 100644 index 0000000..ec9185c --- /dev/null +++ b/src/main/java/com/test/payment/dto/AirtelResponseDto.java @@ -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; + } +} diff --git a/src/main/java/com/test/payment/dto/CallbackAckDto.java b/src/main/java/com/test/payment/dto/CallbackAckDto.java new file mode 100644 index 0000000..940de84 --- /dev/null +++ b/src/main/java/com/test/payment/dto/CallbackAckDto.java @@ -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); + } +} diff --git a/src/main/java/com/test/payment/dto/ErrorResponseDto.java b/src/main/java/com/test/payment/dto/ErrorResponseDto.java new file mode 100644 index 0000000..937d8e1 --- /dev/null +++ b/src/main/java/com/test/payment/dto/ErrorResponseDto.java @@ -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 details; + private LocalDateTime timestamp; + + public static ErrorResponseDto of(String error, String message) { + return new ErrorResponseDto("FAILED", error, message, null, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/test/payment/models/MpesaResponse.java b/src/main/java/com/test/payment/dto/MpesaResponse.java similarity index 94% rename from src/main/java/com/test/payment/models/MpesaResponse.java rename to src/main/java/com/test/payment/dto/MpesaResponse.java index 4bda54f..859daa6 100644 --- a/src/main/java/com/test/payment/models/MpesaResponse.java +++ b/src/main/java/com/test/payment/dto/MpesaResponse.java @@ -1,4 +1,4 @@ -package com.test.payment.models; +package com.test.payment.dto; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/test/payment/dto/MtnPayRequestDto.java b/src/main/java/com/test/payment/dto/MtnPayRequestDto.java new file mode 100644 index 0000000..f3d9411 --- /dev/null +++ b/src/main/java/com/test/payment/dto/MtnPayRequestDto.java @@ -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; + } +} diff --git a/src/main/java/com/test/payment/dto/MtnStatusResponseDto.java b/src/main/java/com/test/payment/dto/MtnStatusResponseDto.java new file mode 100644 index 0000000..e009e21 --- /dev/null +++ b/src/main/java/com/test/payment/dto/MtnStatusResponseDto.java @@ -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); + } +} diff --git a/src/main/java/com/test/payment/dto/OAuth2TokenResponse.java b/src/main/java/com/test/payment/dto/OAuth2TokenResponse.java new file mode 100644 index 0000000..b5e5cb9 --- /dev/null +++ b/src/main/java/com/test/payment/dto/OAuth2TokenResponse.java @@ -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; + } + } +} diff --git a/src/main/java/com/test/payment/dto/PaymentRequest.java b/src/main/java/com/test/payment/dto/PaymentRequest.java new file mode 100644 index 0000000..ad8a085 --- /dev/null +++ b/src/main/java/com/test/payment/dto/PaymentRequest.java @@ -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; +} diff --git a/src/main/java/com/test/payment/dto/PaymentResultDto.java b/src/main/java/com/test/payment/dto/PaymentResultDto.java new file mode 100644 index 0000000..c2f18de --- /dev/null +++ b/src/main/java/com/test/payment/dto/PaymentResultDto.java @@ -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; +} diff --git a/src/main/java/com/test/payment/dto/StkCallbackPayload.java b/src/main/java/com/test/payment/dto/StkCallbackPayload.java new file mode 100644 index 0000000..2ccf77a --- /dev/null +++ b/src/main/java/com/test/payment/dto/StkCallbackPayload.java @@ -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; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + @JsonProperty("Name") + private String name; + + @JsonProperty("Value") + private Object value; + } +} diff --git a/src/main/java/com/test/payment/dto/StkQueryRequestDto.java b/src/main/java/com/test/payment/dto/StkQueryRequestDto.java new file mode 100644 index 0000000..30041b2 --- /dev/null +++ b/src/main/java/com/test/payment/dto/StkQueryRequestDto.java @@ -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; +} diff --git a/src/main/java/com/test/payment/dto/StkQueryResponseDto.java b/src/main/java/com/test/payment/dto/StkQueryResponseDto.java new file mode 100644 index 0000000..0b95a06 --- /dev/null +++ b/src/main/java/com/test/payment/dto/StkQueryResponseDto.java @@ -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; +} diff --git a/src/main/java/com/test/payment/dto/TransactionStatusDto.java b/src/main/java/com/test/payment/dto/TransactionStatusDto.java new file mode 100644 index 0000000..987b556 --- /dev/null +++ b/src/main/java/com/test/payment/dto/TransactionStatusDto.java @@ -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; +} diff --git a/src/main/java/com/test/payment/exceptions/MpesaBusyException.java b/src/main/java/com/test/payment/exceptions/MpesaBusyException.java deleted file mode 100644 index 8f366ca..0000000 --- a/src/main/java/com/test/payment/exceptions/MpesaBusyException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.test.payment.exceptions; - - -public class MpesaBusyException extends RuntimeException { - public MpesaBusyException(String msg) { - super(msg); - } -} diff --git a/src/main/java/com/test/payment/exceptions/MpesaPermanentException.java b/src/main/java/com/test/payment/exceptions/MpesaPermanentException.java deleted file mode 100644 index 527c550..0000000 --- a/src/main/java/com/test/payment/exceptions/MpesaPermanentException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.test.payment.exceptions; - - -public class MpesaPermanentException extends RuntimeException { - public MpesaPermanentException(String msg) { - super(msg); - } -} diff --git a/src/main/java/com/test/payment/exceptions/MpesaTransientException.java b/src/main/java/com/test/payment/exceptions/MpesaTransientException.java deleted file mode 100644 index cc7d46a..0000000 --- a/src/main/java/com/test/payment/exceptions/MpesaTransientException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.test.payment.exceptions; - -public class MpesaTransientException extends RuntimeException { - public MpesaTransientException(String msg, Throwable cause) { - super(msg, cause); - } -} diff --git a/src/main/java/com/test/payment/exceptions/ProviderBusyException.java b/src/main/java/com/test/payment/exceptions/ProviderBusyException.java new file mode 100644 index 0000000..9b0034e --- /dev/null +++ b/src/main/java/com/test/payment/exceptions/ProviderBusyException.java @@ -0,0 +1,8 @@ +package com.test.payment.exceptions; + + +public class ProviderBusyException extends RuntimeException { + public ProviderBusyException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/test/payment/exceptions/ProviderPermanentException.java b/src/main/java/com/test/payment/exceptions/ProviderPermanentException.java new file mode 100644 index 0000000..9bbccfb --- /dev/null +++ b/src/main/java/com/test/payment/exceptions/ProviderPermanentException.java @@ -0,0 +1,8 @@ +package com.test.payment.exceptions; + + +public class ProviderPermanentException extends RuntimeException { + public ProviderPermanentException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/test/payment/exceptions/ProviderProcessingException.java b/src/main/java/com/test/payment/exceptions/ProviderProcessingException.java new file mode 100644 index 0000000..4666434 --- /dev/null +++ b/src/main/java/com/test/payment/exceptions/ProviderProcessingException.java @@ -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); + } +} diff --git a/src/main/java/com/test/payment/exceptions/ProviderTransientException.java b/src/main/java/com/test/payment/exceptions/ProviderTransientException.java new file mode 100644 index 0000000..a0da308 --- /dev/null +++ b/src/main/java/com/test/payment/exceptions/ProviderTransientException.java @@ -0,0 +1,7 @@ +package com.test.payment.exceptions; + +public class ProviderTransientException extends RuntimeException { + public ProviderTransientException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/src/main/java/com/test/payment/jobs/MpesaTransactionJob.java b/src/main/java/com/test/payment/jobs/MpesaTransactionJob.java deleted file mode 100644 index 6dfeda0..0000000 --- a/src/main/java/com/test/payment/jobs/MpesaTransactionJob.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java new file mode 100644 index 0000000..150b51c --- /dev/null +++ b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java @@ -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 servicesByProvider; + private final AtomicBoolean running = new AtomicBoolean(false); + + public PaymentReconciliationJob(PaymentInitiationRepository initiationRepository, + PaymentLifecycleService lifecycle, + Environment environment, + List 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())); + } +} diff --git a/src/main/java/com/test/payment/models/DatabaseSchema.java b/src/main/java/com/test/payment/models/DatabaseSchema.java new file mode 100644 index 0000000..6971684 --- /dev/null +++ b/src/main/java/com/test/payment/models/DatabaseSchema.java @@ -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 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) + ) + """ + ); +} diff --git a/src/main/java/com/test/payment/models/PaymentCallback.java b/src/main/java/com/test/payment/models/PaymentCallback.java new file mode 100644 index 0000000..f371279 --- /dev/null +++ b/src/main/java/com/test/payment/models/PaymentCallback.java @@ -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; +} diff --git a/src/main/java/com/test/payment/models/PaymentInitiation.java b/src/main/java/com/test/payment/models/PaymentInitiation.java new file mode 100644 index 0000000..642b27e --- /dev/null +++ b/src/main/java/com/test/payment/models/PaymentInitiation.java @@ -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; +} diff --git a/src/main/java/com/test/payment/models/PaymentProviderType.java b/src/main/java/com/test/payment/models/PaymentProviderType.java new file mode 100644 index 0000000..05e619b --- /dev/null +++ b/src/main/java/com/test/payment/models/PaymentProviderType.java @@ -0,0 +1,7 @@ +package com.test.payment.models; + +public enum PaymentProviderType { + MPESA, + AIRTEL, + MTN +} diff --git a/src/main/java/com/test/payment/models/PaymentRequest.java b/src/main/java/com/test/payment/models/PaymentRequest.java deleted file mode 100644 index 7000d4a..0000000 --- a/src/main/java/com/test/payment/models/PaymentRequest.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/com/test/payment/models/PaymentResponse.java b/src/main/java/com/test/payment/models/PaymentResponse.java new file mode 100644 index 0000000..71630f8 --- /dev/null +++ b/src/main/java/com/test/payment/models/PaymentResponse.java @@ -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; +} diff --git a/src/main/java/com/test/payment/models/ProviderToken.java b/src/main/java/com/test/payment/models/ProviderToken.java new file mode 100644 index 0000000..31012f5 --- /dev/null +++ b/src/main/java/com/test/payment/models/ProviderToken.java @@ -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; +} diff --git a/src/main/java/com/test/payment/models/Transaction.java b/src/main/java/com/test/payment/models/Transaction.java index 076a26b..815d51c 100644 --- a/src/main/java/com/test/payment/models/Transaction.java +++ b/src/main/java/com/test/payment/models/Transaction.java @@ -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; } - diff --git a/src/main/java/com/test/payment/models/TransactionStatus.java b/src/main/java/com/test/payment/models/TransactionStatus.java new file mode 100644 index 0000000..d5df8db --- /dev/null +++ b/src/main/java/com/test/payment/models/TransactionStatus.java @@ -0,0 +1,7 @@ +package com.test.payment.models; + +public enum TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/src/main/java/com/test/payment/repository/MpesaRepository.java b/src/main/java/com/test/payment/repository/MpesaRepository.java deleted file mode 100644 index 289fa77..0000000 --- a/src/main/java/com/test/payment/repository/MpesaRepository.java +++ /dev/null @@ -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 { - - /* public Mono saveTransaction(Transaction transaction) { - return Mono.fromRunnable(() -> - redisTemplate.opsForHash().put("mpesa:transactions", transaction.getId(), transaction.getStatus()) - ).then(); - } - - public Flux findAllTransactions() { - List values = redisTemplate.opsForHash().values("mpesa:transactions"); - return Flux.fromIterable(values).cast(Transaction.class); - }*/ - - Flux findByStatus(String status); - Mono findByCheckoutRequestId(String checkoutRequestId); - - /* private final RedisTemplate redisTemplate; - - public MpesaRepository(RedisTemplate redisTemplate) { - this.redisTemplate = redisTemplate; - } - - */ -} diff --git a/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java b/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java new file mode 100644 index 0000000..86a0bc7 --- /dev/null +++ b/src/main/java/com/test/payment/repository/PaymentCallbackRepository.java @@ -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 { + + Mono findByInitiationId(Long initiationId); +} diff --git a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java new file mode 100644 index 0000000..b15206e --- /dev/null +++ b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java @@ -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 { + + Flux findByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff); +} diff --git a/src/main/java/com/test/payment/repository/PaymentResponseRepository.java b/src/main/java/com/test/payment/repository/PaymentResponseRepository.java new file mode 100644 index 0000000..019d9cb --- /dev/null +++ b/src/main/java/com/test/payment/repository/PaymentResponseRepository.java @@ -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 { + + Mono findByInitiationId(Long initiationId); + + Mono findByProviderReference(String providerReference); +} diff --git a/src/main/java/com/test/payment/repository/ProviderTokenRepository.java b/src/main/java/com/test/payment/repository/ProviderTokenRepository.java new file mode 100644 index 0000000..d7d7a2a --- /dev/null +++ b/src/main/java/com/test/payment/repository/ProviderTokenRepository.java @@ -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 { + + Mono findFirstByProviderAndExpiresAtAfterOrderByIdDesc(String provider, LocalDateTime cutoff); + + Mono deleteByProvider(String provider); +} diff --git a/src/main/java/com/test/payment/repository/TransactionRepository.java b/src/main/java/com/test/payment/repository/TransactionRepository.java new file mode 100644 index 0000000..f941bce --- /dev/null +++ b/src/main/java/com/test/payment/repository/TransactionRepository.java @@ -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 { + + Mono findByInitiationId(Long initiationId); + + Flux findByProvider(String provider); + + Flux findByStatus(String status); +} diff --git a/src/main/java/com/test/payment/service/AirtelService.java b/src/main/java/com/test/payment/service/AirtelService.java new file mode 100644 index 0000000..5475671 --- /dev/null +++ b/src/main/java/com/test/payment/service/AirtelService.java @@ -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 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 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 checkStatus(String providerReference) { + return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); + } + + private Mono 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; + } + } +} diff --git a/src/main/java/com/test/payment/service/AirtelTokenService.java b/src/main/java/com/test/payment/service/AirtelTokenService.java new file mode 100644 index 0000000..85b04a7 --- /dev/null +++ b/src/main/java/com/test/payment/service/AirtelTokenService.java @@ -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 getToken() { + return tokenCache.getToken(PaymentProviderType.AIRTEL.name(), this::fetchToken); + } + + public Mono evictToken() { + return tokenCache.evictToken(PaymentProviderType.AIRTEL.name()); + } + + private Mono 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())); + } +} diff --git a/src/main/java/com/test/payment/service/MpesaService.java b/src/main/java/com/test/payment/service/MpesaService.java index 9657783..f9497b6 100644 --- a/src/main/java/com/test/payment/service/MpesaService.java +++ b/src/main/java/com/test/payment/service/MpesaService.java @@ -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 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 initiatePayment(PaymentRequest request) { - String businessShortCode = environment.getProperty("mpesa.business-short-code"); + @Override + public Mono 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 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 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 checkStatus(String providerReference) { + return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); + } + + private Mono 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 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 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 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 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 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; + } } } - diff --git a/src/main/java/com/test/payment/service/MpesaServiceaa.java b/src/main/java/com/test/payment/service/MpesaServiceaa.java deleted file mode 100644 index c8d9aa9..0000000 --- a/src/main/java/com/test/payment/service/MpesaServiceaa.java +++ /dev/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 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 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 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> 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 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 getToken() { - return Mono.fromSupplier(() -> "Q3HCot6dNLTpUtpitkr5Tatsa4KB"); - } - - public Mono 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 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) - ); - }*/ -} diff --git a/src/main/java/com/test/payment/service/MpesaTokenService.java b/src/main/java/com/test/payment/service/MpesaTokenService.java index f39537d..219dd80 100644 --- a/src/main/java/com/test/payment/service/MpesaTokenService.java +++ b/src/main/java/com/test/payment/service/MpesaTokenService.java @@ -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 redisTemplate; + private final WebClient mpesaWebClient; + private final TokenCacheService tokenCache; + private final Environment environment; public Mono 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 evictToken() { + return tokenCache.evictToken(PaymentProviderType.MPESA.name()); + } + + private Mono 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()))); } } diff --git a/src/main/java/com/test/payment/service/MtnService.java b/src/main/java/com/test/payment/service/MtnService.java new file mode 100644 index 0000000..aea8025 --- /dev/null +++ b/src/main/java/com/test/payment/service/MtnService.java @@ -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 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 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 checkStatus(String providerReference) { + return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); + } + + private Mono 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; + } + } +} diff --git a/src/main/java/com/test/payment/service/MtnTokenService.java b/src/main/java/com/test/payment/service/MtnTokenService.java new file mode 100644 index 0000000..515bace --- /dev/null +++ b/src/main/java/com/test/payment/service/MtnTokenService.java @@ -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 getToken() { + return tokenCache.getToken(PaymentProviderType.MTN.name(), this::fetchToken); + } + + public Mono evictToken() { + return tokenCache.evictToken(PaymentProviderType.MTN.name()); + } + + private Mono 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())); + } +} diff --git a/src/main/java/com/test/payment/service/PaymentLifecycleService.java b/src/main/java/com/test/payment/service/PaymentLifecycleService.java new file mode 100644 index 0000000..c380371 --- /dev/null +++ b/src/main/java/com/test/payment/service/PaymentLifecycleService.java @@ -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 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 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 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 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 checkStatus(String provider, String providerReference, + Function> querier) { + return responseRepository.findByProviderReference(providerReference) + .filter(response -> provider.equals(response.getProvider())) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, + "No " + provider + " transaction found for reference " + providerReference))) + .flatMap(response -> initiationRepository.findById(response.getInitiationId()) + .flatMap(initiation -> TransactionStatus.PENDING.name().equals(initiation.getStatus()) + ? querier.apply(response).flatMap(outcome -> applyQueryOutcome(initiation, response, outcome)) + : buildStatusDto(initiation, response))); + } + + /** + * Used by the reconciliation job for PENDING initiations older than the configured age. + */ + public Mono reconcile(PaymentInitiation initiation, + Function> statusChecker) { + return responseRepository.findByInitiationId(initiation.getId()) + .flatMap(response -> { + if (response.getProviderReference() == null) { + return failTerminal(initiation, "No provider reference on response").thenReturn(true); + } + return statusChecker.apply(response.getProviderReference()).thenReturn(true); + }) + .switchIfEmpty(Mono.defer(() -> { + log.warn("[{}] initiation {} never received a provider response — marking FAILED", + initiation.getProvider(), initiation.getId()); + return failTerminal(initiation, "No provider response received").thenReturn(false); + })) + .then(); + } + + public Flux listTransactions(String provider) { + return provider == null ? transactionRepository.findAll() : transactionRepository.findByProvider(provider); + } + + private Mono failTerminal(PaymentInitiation initiation, String reason) { + return updateStatus(initiation, TransactionStatus.FAILED.name()) + .flatMap(updated -> recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION")); + } + + private Mono saveCallback(PaymentResponse response, CallbackData data, String rawPayload) { + PaymentCallback entity = PaymentCallback.builder() + .initiationId(response.getInitiationId()) + .provider(response.getProvider()) + .providerReference(data.providerReference()) + .resultCode(data.resultCode()) + .resultDesc(data.resultDesc()) + .receiptNumber(data.receiptNumber()) + .amount(data.amount()) + .phoneNumber(data.phoneNumber()) + .transactionDate(data.transactionDate()) + .rawPayload(rawPayload) + .createdAt(LocalDateTime.now()) + .build(); + + String newStatus = data.success() ? TransactionStatus.SUCCESS.name() : TransactionStatus.FAILED.name(); + return callbackRepository.save(entity) + .onErrorResume(DuplicateKeyException.class, + e -> callbackRepository.findByInitiationId(response.getInitiationId())) + .flatMap(saved -> initiationRepository.findById(response.getInitiationId()) + .flatMap(initiation -> updateStatus(initiation, newStatus)) + .flatMap(updated -> recordTransaction(updated, response, data.resultCode(), + data.resultDesc(), data.receiptNumber(), data.transactionDate(), "CALLBACK"))) + .doOnNext(tx -> log.info("[{}] callback processed for initiation {} — status {}", + tx.getProvider(), tx.getInitiationId(), tx.getStatus())) + .thenReturn(CallbackAckDto.accepted("Callback processed")); + } + + private Mono applyQueryOutcome(PaymentInitiation initiation, PaymentResponse response, + QueryOutcome outcome) { + if (outcome.newStatus() == TransactionStatus.PENDING) { + return buildStatusDto(initiation, response) + .map(dto -> { + if (outcome.resultDesc() != null) { + dto.setResultDesc(outcome.resultDesc()); + } + return dto; + }); + } + return updateStatus(initiation, outcome.newStatus().name()) + .flatMap(updated -> recordTransaction(updated, response, outcome.resultCode(), + outcome.resultDesc(), outcome.receiptNumber(), null, "QUERY") + .then(buildStatusDto(updated, response))) + .map(dto -> { + dto.setResultCode(outcome.resultCode()); + dto.setResultDesc(outcome.resultDesc()); + if (outcome.receiptNumber() != null) { + dto.setReceiptNumber(outcome.receiptNumber()); + } + return dto; + }); + } + + private Mono buildStatusDto(PaymentInitiation initiation, PaymentResponse response) { + // result details come from the callback when we have one, otherwise from the + // consolidated transaction row (e.g. when a status query resolved the payment) + return callbackRepository.findByInitiationId(initiation.getId()) + .map(Optional::of) + .defaultIfEmpty(Optional.empty()) + .zipWith(transactionRepository.findByInitiationId(initiation.getId()) + .map(Optional::of) + .defaultIfEmpty(Optional.empty())) + .map(tuple -> { + Optional cb = tuple.getT1(); + Optional tx = tuple.getT2(); + return TransactionStatusDto.builder() + .initiationId(initiation.getId()) + .provider(initiation.getProvider()) + .providerReference(response.getProviderReference()) + .secondaryReference(response.getSecondaryReference()) + .status(initiation.getStatus()) + .phoneNumber(initiation.getPhoneNumber()) + .amount(initiation.getAmount()) + .accountReference(initiation.getAccountReference()) + .resultCode(cb.map(PaymentCallback::getResultCode) + .or(() -> tx.map(Transaction::getResultCode)).orElse(null)) + .resultDesc(cb.map(PaymentCallback::getResultDesc) + .or(() -> tx.map(Transaction::getResultDesc)).orElse(null)) + .receiptNumber(cb.map(PaymentCallback::getReceiptNumber) + .or(() -> tx.map(Transaction::getReceiptNumber)).orElse(null)) + .createdAt(initiation.getCreatedAt()) + .updatedAt(initiation.getUpdatedAt()) + .build(); + }); + } + + private Mono updateStatus(PaymentInitiation initiation, String status) { + initiation.setStatus(status); + initiation.setUpdatedAt(LocalDateTime.now()); + return initiationRepository.save(initiation); + } + + /** + * 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 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); + } +} diff --git a/src/main/java/com/test/payment/service/PaymentProviderService.java b/src/main/java/com/test/payment/service/PaymentProviderService.java new file mode 100644 index 0000000..cf342d8 --- /dev/null +++ b/src/main/java/com/test/payment/service/PaymentProviderService.java @@ -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 initiatePayment(PaymentRequest request); + + Mono checkStatus(String providerReference); +} diff --git a/src/main/java/com/test/payment/service/TokenCacheService.java b/src/main/java/com/test/payment/service/TokenCacheService.java new file mode 100644 index 0000000..7690e3f --- /dev/null +++ b/src/main/java/com/test/payment/service/TokenCacheService.java @@ -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 getToken(String provider, Supplier> 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 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 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 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 fetchAndStore(String provider, Supplier> fetcher) { + return fetcher.get() + .flatMap(fetched -> { + ProviderToken token = ProviderToken.builder() + .provider(provider) + .accessToken(fetched.accessToken()) + .expiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds())) + .createdAt(LocalDateTime.now()) + .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 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); + } +} diff --git a/src/main/java/com/test/payment/utils/MpesaUtils.java b/src/main/java/com/test/payment/utils/MpesaUtils.java index 6cedc3a..81060ee 100644 --- a/src/main/java/com/test/payment/utils/MpesaUtils.java +++ b/src/main/java/com/test/payment/utils/MpesaUtils.java @@ -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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d45436..4c95555 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 \ No newline at end of file + 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 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 9930216..0000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -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 -);