Initial commit
This commit is contained in:
15
src/main/java/com/test/payment/PaymentApplication.java
Normal file
15
src/main/java/com/test/payment/PaymentApplication.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.test.payment;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class PaymentApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PaymentApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.test.payment.configurations;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public LettuceConnectionFactory redisConnectionFactory() {
|
||||
return new LettuceConnectionFactory();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(LettuceConnectionFactory factory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.test.payment.configurations;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient mpesaWebClient(WebClient.Builder builder) {
|
||||
return builder
|
||||
.baseUrl("https://sandbox.safaricom.co.ke")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
|
||||
import com.test.payment.models.*;
|
||||
import com.test.payment.models.MpesaResponse;
|
||||
import com.test.payment.models.PaymentRequest;
|
||||
import com.test.payment.service.MpesaService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/mpesa")
|
||||
@RequiredArgsConstructor
|
||||
public class MpesaController {
|
||||
|
||||
private final MpesaService mpesaService;
|
||||
|
||||
@PostMapping("/pay")
|
||||
public Mono<ResponseEntity<MpesaResponse>> pay(@RequestBody PaymentRequest request) {
|
||||
return mpesaService.initiatePayment(request)
|
||||
.map(ResponseEntity::ok)
|
||||
.onErrorResume(ex -> Mono.just(ResponseEntity.badRequest()
|
||||
.body(new MpesaResponse("FAILED", ex.getMessage()))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
|
||||
public class MpesaPermanentException extends RuntimeException {
|
||||
public MpesaPermanentException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
public class MpesaTransientException extends RuntimeException {
|
||||
public MpesaTransientException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
23
src/main/java/com/test/payment/jobs/MpesaTransactionJob.java
Normal file
23
src/main/java/com/test/payment/jobs/MpesaTransactionJob.java
Normal file
@@ -0,0 +1,23 @@
|
||||
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.findAllTransactions()
|
||||
.doOnNext(tx -> log.info("Checking transaction: {}", tx))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
13
src/main/java/com/test/payment/models/MpesaResponse.java
Normal file
13
src/main/java/com/test/payment/models/MpesaResponse.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class MpesaResponse {
|
||||
private String status;
|
||||
private String message;
|
||||
}
|
||||
12
src/main/java/com/test/payment/models/PaymentRequest.java
Normal file
12
src/main/java/com/test/payment/models/PaymentRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PaymentRequest {
|
||||
private String phoneNumber;
|
||||
private double amount;
|
||||
private String accountReference;
|
||||
private String transactionDesc;
|
||||
}
|
||||
21
src/main/java/com/test/payment/models/Transaction.java
Normal file
21
src/main/java/com/test/payment/models/Transaction.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
public class Transaction {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
private String phoneNumber;
|
||||
private double amount;
|
||||
private String status;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
|
||||
import com.test.payment.models.Transaction;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class MpesaRepository {
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
public MpesaRepository(RedisTemplate<String, String> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
public Mono<Void> saveTransaction(Transaction transaction) {
|
||||
return Mono.fromRunnable(() ->
|
||||
redisTemplate.opsForHash().put("mpesa:transactions", transaction.getId(), transaction.getStatus())
|
||||
).then();
|
||||
}
|
||||
|
||||
public Flux<Transaction> findAllTransactions() {
|
||||
List<Object> values = redisTemplate.opsForHash().values("mpesa:transactions");
|
||||
return Flux.fromIterable(values).cast(Transaction.class);
|
||||
}
|
||||
}
|
||||
65
src/main/java/com/test/payment/service/MpesaService.java
Normal file
65
src/main/java/com/test/payment/service/MpesaService.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import com.test.payment.exceptions.MpesaPermanentException;
|
||||
import com.test.payment.exceptions.MpesaTransientException;
|
||||
import com.test.payment.models.*;
|
||||
import com.test.payment.repository.MpesaRepository;
|
||||
import io.github.resilience4j.circuitbreaker.*;
|
||||
import io.github.resilience4j.ratelimiter.*;
|
||||
import io.github.resilience4j.retry.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaService {
|
||||
|
||||
private final WebClient mpesaWebClient;
|
||||
private final MpesaRepository mpesaRepository;
|
||||
private final MpesaTokenService tokenService;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final Retry retry;
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
|
||||
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
|
||||
Supplier<Mono<MpesaResponse>> decoratedSupplier =
|
||||
CircuitBreaker.decorateSupplier(circuitBreaker,
|
||||
RateLimiter.decorateSupplier(rateLimiter,
|
||||
Retry.decorateSupplier(retry, () -> callMpesa(request))
|
||||
)
|
||||
);
|
||||
|
||||
return Mono.defer(decoratedSupplier)
|
||||
.flatMap(response -> {
|
||||
Transaction tx = new Transaction(UUID.randomUUID().toString(),
|
||||
request.getPhoneNumber(), request.getAmount(), response.getStatus());
|
||||
return mpesaRepository.saveTransaction(tx).thenReturn(response);
|
||||
})
|
||||
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
private Mono<MpesaResponse> callMpesa(PaymentRequest request) {
|
||||
return tokenService.getToken()
|
||||
.flatMap(token ->
|
||||
mpesaWebClient.post()
|
||||
.uri("/mpesa/stkpush/v1/processrequest")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(MpesaResponse.class)
|
||||
.timeout(java.time.Duration.ofSeconds(20))
|
||||
.onErrorResume(ex -> {
|
||||
log.error("Error calling M-Pesa: {}", ex.getMessage());
|
||||
return Mono.error(new MpesaTransientException("Temporary M-Pesa issue", ex));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MpesaTokenService {
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
String cachedToken = redisTemplate.opsForValue().get("mpesa:token");
|
||||
if (cachedToken != null) {
|
||||
return Mono.just(cachedToken);
|
||||
}
|
||||
// Simulate token fetch from M-Pesa auth endpoint
|
||||
String newToken = "access_token_" + System.currentTimeMillis();
|
||||
redisTemplate.opsForValue().set("mpesa:token", newToken, Duration.ofMinutes(50));
|
||||
return Mono.just(newToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user