Initial commit

This commit is contained in:
NewUsername
2025-10-10 12:56:49 +03:00
parent f9579e4839
commit bec7093889
42 changed files with 1574 additions and 0 deletions

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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()))));
}
}

View File

@@ -0,0 +1,8 @@
package com.test.payment.exceptions;
public class MpesaPermanentException extends RuntimeException {
public MpesaPermanentException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,7 @@
package com.test.payment.exceptions;
public class MpesaTransientException extends RuntimeException {
public MpesaTransientException(String msg, Throwable cause) {
super(msg, cause);
}
}

View 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();
}
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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);
}
}

View 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));
})
);
}
}

View File

@@ -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);
}
}