poc completed

This commit is contained in:
2025-07-11 12:25:46 +02:00
parent 6ad1f55d4b
commit 1ba7a8cda4
28 changed files with 807 additions and 88 deletions

View File

@@ -0,0 +1,17 @@
package zw.qantra.tm.configs;
import net.kaczmarzyk.spring.data.jpa.web.SpecificationArgumentResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class SpecificationConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new SpecificationArgumentResolver());
}
}

View File

@@ -11,10 +11,12 @@ import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.Category;
import zw.qantra.tm.domain.models.Charge;
import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.models.IntegrationProcessor;
import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.services.ChargeConditionService;
import zw.qantra.tm.domain.services.ChargeService;
import zw.qantra.tm.domain.services.IntegrationProcessorService;
import zw.qantra.tm.domain.services.CategoryService;
import zw.qantra.tm.domain.services.PaymentProcessorService;
import zw.qantra.tm.domain.services.ProviderService;
@@ -33,9 +35,20 @@ public class ConfigController {
private final PaymentProcessorService paymentProcessorService;
private final ProviderService providerService;
private final CategoryService categoryService;
private final IntegrationProcessorService integrationProcessorService;
@GetMapping("/seed")
public ResponseEntity charges(){
integrationProcessorService.getIntegrationProcessorRepository().deleteAll();
integrationProcessorService.getIntegrationProcessorRepository().saveAll(Arrays.asList(
IntegrationProcessor.builder()
.name("STEWARD")
.label("STEWARD")
.description("Steward Integration Processor")
.build()
));
// clear existing categories before seeding
categoryService.getCategoryRepository().deleteAll();
// seed categories
@@ -65,6 +78,7 @@ public class ConfigController {
.category("AIRTIME")
.image("econet.png")
.externalId("78f1f497-c9eb-401c-b22c-884756e68e40")
.integrationProcessorLabel("STEWARD")
.build(),
Provider.builder()
.description("NetOne Airtime")
@@ -73,6 +87,7 @@ public class ConfigController {
.category("AIRTIME")
.image("netone.png")
.externalId("95d485a7-39c9-4559-8a27-6521969abe8f")
.integrationProcessorLabel("STEWARD")
.build(),
Provider.builder()
.description("Prepaid Zesa")
@@ -81,6 +96,7 @@ public class ConfigController {
.category("UTILITIES")
.image("zesa.png")
.externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6")
.integrationProcessorLabel("STEWARD")
.build(),
Provider.builder()
.description("Econet Broadband")
@@ -89,6 +105,7 @@ public class ConfigController {
.category("AIRTIME")
.image("econet.png")
.externalId("287863e2-a791-4106-b629-79dadc9a6779")
.integrationProcessorLabel("STEWARD")
.build()
);
providerService.getProviderRepository().saveAll(providers);

View File

@@ -17,7 +17,7 @@ public class ProviderController {
@GetMapping
public ResponseEntity getProviders(@RequestParam(required = false) String category) {
return ResponseEntity.ok(providerService.getProviders(category));
return ResponseEntity.ok(providerService.getProvidersByCategory(category));
}
@GetMapping("/{id}/products")
@@ -25,6 +25,16 @@ public class ProviderController {
return ResponseEntity.ok(providerService.getProviderProducts(id));
}
@GetMapping("/{id}/products/{productId}")
public ResponseEntity getProducts(@PathVariable UUID id, @PathVariable UUID productId) {
return ResponseEntity.ok(providerService.getProviderProduct(id, productId));
}
@GetMapping("/client/{clientId}")
public ResponseEntity getProviderByClientId(@PathVariable String clientId) {
return ResponseEntity.ok(providerService.getProviderByClientId(clientId));
}
@GetMapping("/{id}")
public ResponseEntity getProvider(@PathVariable UUID id) {
return providerService.getProvider(id)

View File

@@ -0,0 +1,73 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.Like;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Or;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.services.RecipientService;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/recipients")
@RequiredArgsConstructor
public class RecipientController {
private final RecipientService recipientService;
@GetMapping
public ResponseEntity<List<Recipient>> getAllRecipients() {
return ResponseEntity.ok(recipientService.getAllRecipients());
}
@GetMapping("/{account}/user/{userId}")
public ResponseEntity<Recipient> getRecipient(@PathVariable String account, @PathVariable String userId) {
List<Recipient> recipients = recipientService.getRecipient(account, userId);
if(recipients.size() > 0){
return ResponseEntity.ok(recipients.get(0));
}
return ResponseEntity.notFound().build();
}
@PostMapping
public ResponseEntity<Recipient> createRecipient(@RequestBody Recipient recipient) {
return ResponseEntity.ok(recipientService.save(recipient));
}
@PutMapping("/{id}")
public ResponseEntity<Recipient> updateRecipient(@PathVariable UUID id, @RequestBody Recipient recipient) {
Recipient updatedRecipient = recipientService.updateRecipient(id, recipient);
if (updatedRecipient != null) {
return ResponseEntity.ok(updatedRecipient);
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<String> deleteRecipient(@PathVariable UUID id) {
if (recipientService.deleteRecipient(id)) {
return ResponseEntity.ok("Recipient deleted successfully");
}
return ResponseEntity.notFound().build();
}
@GetMapping("/search")
public ResponseEntity<List<Recipient>> searchRecipients(
@And({
@Spec(path = "name", spec = Equal.class),
@Spec(path = "email", spec = Equal.class),
@Spec(path = "phoneNumber", spec = Equal.class),
@Spec(path = "account", spec = Equal.class),
@Spec(path = "latestProviderLabel", spec = Equal.class),
@Spec(path = "userId", spec = Equal.class)
}) Specification<Recipient> specification) {
return ResponseEntity.ok(recipientService.searchRecipients(specification));
}
}

View File

@@ -1,6 +1,12 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Or;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Conjunction;
import org.springframework.data.jpa.domain.Specification;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.models.Transaction;
@@ -8,6 +14,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.utils.Utils;
import java.util.List;
import java.util.UUID;
@@ -21,15 +28,40 @@ public class TransactonController {
private final TransactionService transactionService;
@GetMapping
public ResponseEntity<List<Transaction>> getAll() {
return ResponseEntity.ok(transactionService.getTransactionRepository().findAll());
public ResponseEntity<List<Transaction>> getAll(
@Conjunction(value = {
@Or({
@Spec(path = "status", spec = Equal.class),
@Spec(path = "createdAt", spec = Equal.class),
@Spec(path = "providerLabel", spec = Equal.class),
@Spec(path = "billClientId", spec = Equal.class),
@Spec(path = "productUid", spec = Equal.class),
@Spec(path = "productName", spec = Equal.class),
@Spec(path = "debitName", spec = Equal.class),
@Spec(path = "debitAccount", spec = Equal.class),
@Spec(path = "debitCurrency", spec = Equal.class),
@Spec(path = "debitPhone", spec = Equal.class),
@Spec(path = "creditName", spec = Equal.class),
@Spec(path = "creditAccount", spec = Equal.class),
@Spec(path = "creditCurrency", spec = Equal.class),
@Spec(path = "creditPhone", spec = Equal.class),
@Spec(path = "amount", spec = Equal.class),
@Spec(path = "reference", spec = Equal.class),
@Spec(path = "totalAmount", spec = Equal.class),
@Spec(path = "authType", spec = Equal.class),
@Spec(path = "sdkActionId", spec = Equal.class),
@Spec(path = "paymentProcessorLabel", spec = Equal.class),
})
}, and = {
@Spec(path = "type", spec = Equal.class),
@Spec(path = "userId", spec = Equal.class),
}) Specification<Transaction> specification) {
return ResponseEntity.ok(transactionService.findAll(specification));
}
@GetMapping("/{id}")
public ResponseEntity<Transaction> getById(@PathVariable UUID id) {
return transactionService.getTransactionRepository().findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
return ResponseEntity.ok(transactionService.findById(id));
}
@GetMapping("/poll/{id}")
@@ -39,9 +71,9 @@ public class TransactonController {
@PostMapping
public ResponseEntity<Transaction> create(@RequestBody Transaction transaction) {
logger.info("incoming transaction: {}", transaction);
logger.info("incoming transaction: {}", Utils.toJson(transaction));
Transaction createdTransaction = transactionService.execute(transaction);
logger.info("outgoing transaction: {}", createdTransaction);
logger.info("outgoing transaction: {}", Utils.toJson(createdTransaction));
return ResponseEntity.ok(createdTransaction);
}

View File

@@ -5,6 +5,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import zw.qantra.tm.domain.enums.AuthType;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType;
@@ -17,6 +18,7 @@ import java.math.BigDecimal;
@JsonIgnoreProperties(ignoreUnknown = true)
public class BillPaymentDto {
private RequestType type;
private AuthType authType;
private String billClientId;
private String debitRef;
private CurrencyType debitCurrency;
@@ -28,4 +30,5 @@ public class BillPaymentDto {
private CurrencyType creditCurrency;
private String creditPhone;
private String billName;
private String trace;
}

View File

@@ -6,10 +6,13 @@ import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@@ -25,4 +28,13 @@ public class BaseEntity {
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
@CreationTimestamp
@Column(name = "created_at", nullable = true, updatable = false)
private LocalDateTime createdAt; // Or Date, Timestamp
@UpdateTimestamp
@Column(name = "updated_at", nullable = true)
private LocalDateTime updatedAt; // Or Date, Timestamp
}

View File

@@ -17,4 +17,6 @@ public class Provider extends BaseEntity {
private String externalId;
private String image;
private String category;
private String accountFieldName;
private String integrationProcessorLabel;
}

View File

@@ -0,0 +1,21 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Recipient extends BaseEntity {
private String name;
private String email;
private String phoneNumber;
private String address;
private String account;
private String initials;
private String latestProviderLabel;
private String userId;
}

View File

@@ -18,6 +18,7 @@ import java.math.BigDecimal;
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Transaction extends BaseEntity {
private String userId;
private String trace;
private BigDecimal amount;
private BigDecimal charge;
@@ -40,6 +41,7 @@ public class Transaction extends BaseEntity {
private String debitName;
private String debitCard;
private String debitRef;
private String debitEmail;
private String creditPhone;
private String creditAccount;
@Enumerated(EnumType.STRING)
@@ -47,11 +49,15 @@ public class Transaction extends BaseEntity {
private String creditName;
private String creditCard;
private String creditRef;
private String creditEmail;
private String billClientId;
private String clientSecret;
private String aggregatorId;
private String billName;
private String billProductName;
private String paymentProcessorName;
private String providerImage;
private String paymentProcessorImage;
@Enumerated(EnumType.STRING)
private RequestType type;
@Enumerated(EnumType.STRING)
@@ -60,6 +66,7 @@ public class Transaction extends BaseEntity {
@Column(columnDefinition = "TEXT")
private String errorMessage;
private String responseCode;
@Enumerated(EnumType.STRING)
private Status status;
@Column(columnDefinition = "TEXT")
private String targetUrl;

View File

@@ -0,0 +1,17 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.Recipient;
import java.util.List;
import java.util.UUID;
@Repository
public interface RecipientRepository extends JpaRepository<Recipient, UUID>, JpaSpecificationExecutor<Recipient> {
List<Recipient> findByNameContainingIgnoreCase(String name);
List<Recipient> findByEmailContainingIgnoreCase(String email);
List<Recipient> findByPhoneNumberContaining(String phoneNumber);
List<Recipient> findByAccountAndUserId(String account, String userId);
}

View File

@@ -1,11 +1,15 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.Transaction;
import java.util.UUID;
@Repository
public interface TransactionRepository extends JpaRepository<Transaction, UUID> {
public interface TransactionRepository extends JpaRepository<Transaction, UUID>, JpaSpecificationExecutor<Transaction> {
Transaction findByTraceAndType(String trace, RequestType type);
}

View File

@@ -30,6 +30,13 @@ public class ProviderService {
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
public SBZProductDto getProviderProduct(UUID providerId, UUID productId) {
return getProviderProducts(providerId).stream()
.filter(product -> product.getUid().equals(productId))
.findFirst()
.orElse(null);
}
public List<SBZProductDto> getProviderProducts(UUID providerId) {
String token = restService.getMerchantToken();
@@ -57,13 +64,15 @@ public class ProviderService {
return products;
}
public Object getProviders(String category) {
String token = restService.getMerchantToken();
public Object getProviderByClientId(String clientId) {
Provider provider = providerRepository.findByClientId(clientId);
if (provider != null) {
return ((List)getProviders(Collections.singletonList(provider))).stream().findFirst().orElse(null);
}
return null;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setBearerAuth(token);
public Object getProvidersByCategory(String category) {
List<Provider> existingProviders;
if (category != null && !category.isEmpty()) {
@@ -72,6 +81,17 @@ public class ProviderService {
existingProviders = getProviderRepository().findAll();
}
return getProviders(existingProviders);
}
public Object getProviders(List<Provider> existingProviders) {
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setBearerAuth(token);
List<String> existingProviderIds = existingProviders.stream()
.map(Provider::getClientId)
.collect(Collectors.toList());
@@ -100,6 +120,7 @@ public class ProviderService {
provider.put("image", existingProvider.getImage());
provider.put("label", existingProvider.getLabel());
provider.put("category", existingProvider.getCategory());
provider.put("accountFieldName", existingProvider.getAccountFieldName());
}
}
}

View File

@@ -0,0 +1,51 @@
package zw.qantra.tm.domain.services;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.repositories.RecipientRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class RecipientService {
@Getter
private final RecipientRepository recipientRepository;
public List<Recipient> getAllRecipients() {
return recipientRepository.findAll();
}
public List<Recipient> getRecipient(String account, String userId) {
return recipientRepository.findByAccountAndUserId(account, userId);
}
public Recipient save(Recipient recipient) {
return recipientRepository.save(recipient);
}
public Recipient updateRecipient(UUID id, Recipient recipient) {
if (recipientRepository.existsById(id)) {
recipient.setId(id);
return recipientRepository.save(recipient);
}
return null;
}
public boolean deleteRecipient(UUID id) {
if (recipientRepository.existsById(id)) {
recipientRepository.deleteById(id);
return true;
}
return false;
}
public List<Recipient> searchRecipients(Specification<Recipient> specification) {
return recipientRepository.findAll(specification);
}
}

View File

@@ -3,23 +3,34 @@ package zw.qantra.tm.domain.services;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.domain.services.processors.handlers.CalculateChargesHandler;
import zw.qantra.tm.domain.services.processors.handlers.ConfirmationLogicHandler;
import zw.qantra.tm.domain.services.processors.handlers.FormattingHandler;
import zw.qantra.tm.domain.services.processors.handlers.LogicPipeline;
import zw.qantra.tm.domain.services.processors.handlers.RecipientsUpdateHandler;
import zw.qantra.tm.domain.services.processors.handlers.ValidationHandler;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
@Service
@RequiredArgsConstructor
@@ -29,42 +40,25 @@ public class TransactionService {
@Getter
private final TransactionRepository transactionRepository;
private final PaymentProcessorFactory paymentProcessorFactory;
private final CalculateChargesHandler calculateChargesHandler;
private final ProviderService providerService;
private final ChargeService chargeService;
private final AdditionalDataService additionalDataService;
private final RecipientService recipientService;
public Transaction poll(UUID uuid){
if(!transactionRepository.existsById(uuid)){
throw new ApiException("Transaction not found for ID: " + uuid);
}
Transaction transaction = transactionRepository.findById(uuid).get();
// no persistence happens during polling unless the transaction status changes
String label = getLabel(transaction);
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
try {
transactionProcessorInterface.poll(transaction);
} catch (Exception e) {
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
}
return transaction;
}
public Transaction execute(Transaction transaction) {
transaction.setReference(UUID.randomUUID().toString());
public Transaction integration(Transaction transaction) {
transaction.setType(RequestType.INTEGRATION);
LogicPipeline<Transaction, Transaction> transactionLogic =
new LogicPipeline<>(calculateChargesHandler)
.addHandler(new ValidationHandler());
new LogicPipeline<>(new ValidationHandler());
transaction = transactionLogic.execute(transaction);
String label = getLabel(transaction);
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
if(provider == null){
throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId());
}
String label = transaction.getType() + "_" +
provider.getIntegrationProcessorLabel();
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
@@ -83,9 +77,80 @@ public class TransactionService {
return transaction;
}
public Transaction poll(UUID uuid){
if(!transactionRepository.existsById(uuid)){
throw new ApiException("Transaction not found for ID: " + uuid);
}
Transaction transaction = transactionRepository.findById(uuid).get();
// no persistence happens during polling unless the transaction status changes
String label = getLabel(transaction);
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
try {
// find out if the gateway tran was successful
transactionProcessorInterface.poll(transaction);
// if tran was successful deep copy transaction & push integration to the aggregator
Transaction transactionCopy = Utils.deepCopy(transaction, Transaction.class);
transactionCopy.setType(RequestType.INTEGRATION);
transactionCopy.setId(null);
integration(transactionCopy);
} catch (Exception e) {
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
}
return transaction;
}
public Transaction execute(Transaction transaction) {
transaction.setReference(UUID.randomUUID().toString());
// run pre transaction logic
LogicPipeline<Transaction, Transaction> preTransactionLogic =
new LogicPipeline<>(new CalculateChargesHandler(chargeService))
.addHandler(new ConfirmationLogicHandler())
.addHandler(new FormattingHandler())
.addHandler(new ValidationHandler());
transaction = preTransactionLogic.execute(transaction);
String label = getLabel(transaction);
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
try {
// MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction);
}catch (Exception e){
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
transactionRepository.save(transaction);
return transaction;
}
// run post transaction logic
LogicPipeline<Transaction, Transaction> postTransactionLogic =
new LogicPipeline<>(new RecipientsUpdateHandler(recipientService));
transaction = postTransactionLogic.execute(transaction);
return transaction;
}
private String getLabel(Transaction transaction){
String label = "";
if(transaction.getType() == RequestType.CONFIRM){
List<RequestType> list = Arrays.asList(RequestType.CONFIRM);
if(list.contains(transaction.getType())){
// get processor label from billClientId
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
@@ -102,6 +167,20 @@ public class TransactionService {
return label;
}
public Transaction findById(UUID id) {
Transaction transaction = transactionRepository.findById(id).orElseThrow(() -> new ApiException("Transaction not found for ID: " + id));
AdditionalData additionalData = additionalDataService.getAdditionalDataRepository()
.findByTransactionId(id).orElseThrow(() -> new ApiException("Additional data not found for transaction ID: " + id));
transaction.setAdditionalData(Utils.fromJson(additionalData.getJsonString(), List.class));
return transaction;
}
public List<Transaction> findAll(Specification<Transaction> spec) {
return transactionRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "createdAt"));
}
public Transaction save(Transaction transaction){
return transactionRepository.save(transaction);
}

View File

@@ -0,0 +1,98 @@
package zw.qantra.tm.domain.services.processors.confirmations;
import lombok.RequiredArgsConstructor;
import org.slf4j.LoggerFactory;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.BillPaymentDto;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
@Service("CONFIRM_ECONET")
@RequiredArgsConstructor
public class EconetConfirmationProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(EconetConfirmationProcessor.class);
private final RestService restService;
private final TransactionService transactionService;
private final AdditionalDataService additionalDataService;
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
@Value("${sbz.aggregator.client-id}")
private String aggregatorClientId;
@Value("${sbz.aggregator.phone}")
private String aggregatorPhone;
@Value("${sbz.aggregator.account}")
private String aggregatorAccount;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("processing transaction");
String token = restService.getMerchantToken();
BillPaymentDto request = BillPaymentDto.builder()
.type(RequestType.CONFIRM)
.billClientId(transaction.getBillClientId())
.debitRef(transaction.getDebitRef())
.debitCurrency(transaction.getDebitCurrency())
.amount(transaction.getAmount())
.aggregatorId(aggregatorClientId)
.debitPhone(aggregatorPhone) // qantra mobile number
.debitAccount(aggregatorAccount) // qantra corporate account
.creditAccount(transaction.getCreditAccount()) // customer's phone number
.creditPhone(transaction.getCreditPhone()) // customer's contact phone number
.billName(transaction.getBillName())
.build();
String url = aggregatorUrl + "/merchant/aggregation/transaction";
LinkedHashMap response = restService.postWithToken(token, url, request, LinkedHashMap.class);
logger.info("response: {}", response.toString());
LinkedHashMap body = (LinkedHashMap) response.get("body");
if (body.get("status").equals("SUCCESS")) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode((String) body.get("responseCode"));
transaction.setAdditionalData(body.get("additionalData"));
transactionService.save(transaction);
AdditionalData additionalData = AdditionalData.builder()
.transaction(transaction)
.jsonString(Utils.toJson(body.get("additionalData")))
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
} else {
transaction.setStatus(Status.FAILED);
transaction.setErrorMessage((String) body.get("message"));
transaction.setResponseCode((String) body.get("responseCode"));
transaction.setErrorMessage((String) body.get("errorMessage"));
}
return transactionService.save(transaction);
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) {
return transaction;
}
}

View File

@@ -80,6 +80,7 @@ public class ZesaConfirmationProcessor implements TransactionProcessorInterface
transaction.setStatus(Status.FAILED);
transaction.setErrorMessage((String) body.get("message"));
transaction.setResponseCode((String) body.get("responseCode"));
transaction.setErrorMessage((String) body.get("errorMessage"));
}
return transactionService.save(transaction);

View File

@@ -1,5 +1,6 @@
package zw.qantra.tm.domain.services.processors.handlers;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -17,11 +18,11 @@ import java.util.List;
@Service
@RequiredArgsConstructor
public class CalculateChargesHandler implements LogicHandler<Transaction, Transaction> {
Logger logger = LoggerFactory.getLogger(CalculateChargesHandler.class);
@Autowired
private ChargeService chargeService;
private final ChargeService chargeService;
@Override
public Transaction process(Transaction transaction) {
@@ -126,14 +127,17 @@ public class CalculateChargesHandler implements LogicHandler<Transaction, Transa
// calculate total amount for ease of use later
transaction.setTotalAmount(
transaction.getAmount().add(
transaction.getCharge().add(
transaction.getTax().add(
transaction.getGatewayCharge())));
transaction.getGatewayCharge()))));
return transaction;
}
void applyCharge(Transaction transaction, Charge charge, BigDecimal chargeAmount) {
if(chargeAmount == null) return;
switch (charge.getChargeLabel()) {
case FEE -> transaction.setCharge(chargeAmount);
case TAX -> transaction.setTax(chargeAmount);

View File

@@ -0,0 +1,15 @@
package zw.qantra.tm.domain.services.processors.handlers;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.utils.Utils;
public class ConfirmationLogicHandler implements LogicHandler<Transaction, Transaction> {
@Override
public Transaction process(Transaction transaction) {
if(transaction.getType().equals(RequestType.CONFIRM)) {
transaction.setTrace(Utils.getUid());
}
return transaction;
}
}

View File

@@ -0,0 +1,59 @@
package zw.qantra.tm.domain.services.processors.handlers;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
import zw.qantra.tm.domain.models.Transaction;
public class FormattingHandler implements LogicHandler<Transaction, Transaction> {
@Override
public Transaction process(Transaction transaction) {
System.out.println("processing formatting");
// strip credit account and make sure there's nothing funny
String creditAccount = transaction.getCreditAccount();
if (creditAccount != null && !creditAccount.isEmpty()) {
// Remove all non-hexadecimal characters (keep only 0-9, a-f, A-F)
String strippedAccount = creditAccount.replaceAll("[^0-9a-fA-F]", "");
// Only set if the stripped result is not empty
if (!strippedAccount.isEmpty()) {
transaction.setCreditAccount(strippedAccount);
} else {
// If stripping results in empty string, set to null
transaction.setCreditAccount(null);
}
}
// sanitize phone number formatting
String debitPhoneNumber = transaction.getDebitPhone();
String creditPhoneNumber = transaction.getCreditPhone();
PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
if(debitPhoneNumber != null && !debitPhoneNumber.isEmpty()){
try {
// phone must begin with '+'
PhoneNumber zimPhoneNumber = phoneUtil.parse(debitPhoneNumber, "");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setDebitPhone(formattedPhoneNumber);
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}
}
if(creditPhoneNumber != null && !creditPhoneNumber.isEmpty()){
try {
PhoneNumber zimPhoneNumber = phoneUtil.parse(creditPhoneNumber, "");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setCreditPhone(formattedPhoneNumber);
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}
}
return transaction;
}
}

View File

@@ -0,0 +1,55 @@
package zw.qantra.tm.domain.services.processors.handlers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.services.RecipientService;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RecipientsUpdateHandler implements LogicHandler<Transaction, Transaction> {
private final RecipientService recipientService;
@Override
public Transaction process(Transaction transaction) {
System.out.println("processing recipients");
if(transaction.getStatus() == Status.SUCCESS){
List<Recipient> recipients = recipientService.getRecipient(transaction.getCreditAccount(), transaction.getUserId());
Recipient recipient = null;
if(recipients.size() > 0){
recipient = recipients.get(0);
}else{
recipient = new Recipient();
recipient.setAccount(transaction.getCreditAccount());
recipient.setUserId(transaction.getUserId());
}
recipient.setLatestProviderLabel(transaction.getProviderLabel());
if(transaction.getCreditName() != null && !transaction.getCreditName().isEmpty()){
recipient.setName(transaction.getCreditName());
}
if(transaction.getCreditEmail() != null && !transaction.getCreditEmail().isEmpty()){
recipient.setEmail(transaction.getCreditEmail());
}
if(transaction.getCreditPhone() != null && !transaction.getCreditPhone().isEmpty()){
recipient.setPhoneNumber(transaction.getCreditPhone());
}
String stringToInitial = transaction.getCreditName() != null && !transaction.getCreditName().isEmpty()
? transaction.getCreditName() : transaction.getBillName();
recipient.setInitials(stringToInitial.substring(0, 2).toUpperCase());
recipientService.save(recipient);
}
return transaction;
}
}

View File

@@ -0,0 +1,112 @@
package zw.qantra.tm.domain.services.processors.integrations;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.BillPaymentDto;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
@Service("INTEGRATION_STEWARD")
@RequiredArgsConstructor
public class StewardIntegrationProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(TransactionService.class);
private final RestService restService;
private final TransactionService transactionService;
private final AdditionalDataService additionalDataService;
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
@Value("${sbz.aggregator.client-id}")
private String aggregatorClientId;
@Value("${sbz.aggregator.phone}")
private String aggregatorPhone;
@Value("${sbz.aggregator.account}")
private String aggregatorAccount;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("processing integration");
String token = restService.getMerchantToken();
BillPaymentDto request = BillPaymentDto.builder()
.type(RequestType.REQUEST)
.billClientId(transaction.getBillClientId())
.debitRef(transaction.getDebitRef())
.debitCurrency(transaction.getDebitCurrency())
.amount(transaction.getAmount())
.aggregatorId(aggregatorClientId)
.debitPhone(aggregatorPhone) // qantra mobile number
.debitAccount(aggregatorAccount) // qantra corporate account
.creditAccount(transaction.getCreditAccount()) // customer's zesa meter number
.creditPhone(transaction.getCreditPhone()) // customer's contact phone number
.billName(transaction.getBillName())
.authType(transaction.getAuthType())
.trace(transaction.getTrace())
.build();
String url = aggregatorUrl + "/merchant/aggregation/transaction";
LinkedHashMap response = restService.postWithToken(token, url, request, LinkedHashMap.class);
logger.info("response: {}", response.toString());
LinkedHashMap body = (LinkedHashMap) response.get("body");
if (body.get("status").equals("SUCCESS")) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode((String) body.get("responseCode"));
transaction.setAdditionalData(body.get("additionalData"));
transactionService.save(transaction);
AdditionalData additionalData = AdditionalData.builder()
.transaction(transaction)
.jsonString(Utils.toJson(body.get("additionalData")))
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
// fetch the corresponding request transaction if it exists
Transaction requestTransaction = transactionService.getTransactionRepository()
.findByTraceAndType(transaction.getTrace(), RequestType.REQUEST);
if (requestTransaction != null) {
// create a new additional data for the request transaction
AdditionalData requestAdditionalData = AdditionalData.builder()
.transaction(requestTransaction)
.jsonString(Utils.toJson(body.get("additionalData")))
.build();
additionalDataService.getAdditionalDataRepository().save(requestAdditionalData);
}
} else {
transaction.setStatus(Status.FAILED);
transaction.setErrorMessage((String) body.get("message"));
transaction.setResponseCode((String) body.get("responseCode"));
transaction.setErrorMessage((String) body.get("errorMessage"));
}
return transactionService.save(transaction);
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) {
return transaction;
}
}

View File

@@ -1,34 +0,0 @@
package zw.qantra.tm.domain.services.processors.integrations;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
@Service("REQUEST_ZESA")
@RequiredArgsConstructor
public class ZesaIntegrationProcessor implements TransactionProcessorInterface {
private final RestService restService;
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
@Override
public Object process(Transaction transaction) throws Exception {
return transaction;
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) {
return transaction;
}
}

View File

@@ -14,6 +14,8 @@ import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,10 +42,13 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
logger.info("processing transaction");
String token = restService.getMerchantToken();
// send total less gateway charges
BigDecimal totalAmount = transaction.getAmount().add(transaction.getTax()).add(transaction.getCharge());
String concat = clientId+
encryptionKey+
transaction.getCreditPhone()+
transaction.getAmount();
totalAmount;
String hex = getHash(concat);
@@ -51,7 +56,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
.clientId(clientId)
.clientSecret(clientSecret)
.phone(transaction.getCreditPhone())
.amount(transaction.getAmount().toString())
.amount(totalAmount.toString())
.currency(transaction.getDebitCurrency().toString())
.hash(hex)
.authType("WEB")
@@ -59,7 +64,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
.action("PAYMENT")
.sandbox(true)
.paymentProcessorLabel("MPGS")
.responseUrl("https://www.google.com")
.responseUrl("https://stewardbank.co.zw")
.build();
HttpHeaders headers = new HttpHeaders();

View File

@@ -12,6 +12,11 @@ import java.security.NoSuchAlgorithmException;
import java.util.UUID;
public class Utils {
public static <T> T deepCopy(Object object, Class<T> clazz) {
String json = toJson(object);
return fromJson(json, clazz);
}
public static String hashPassword(String password) {
try {
// Create a MessageDigest instance for SHA-256

View File

@@ -14,11 +14,15 @@ spring.datasource.url=jdbc:postgresql://localhost:5432/qpay?useLegacyDatetimeCod
spring.datasource.username=postgres
spring.datasource.password=password
sbz.aggregator.url=http://192.168.16.29:24050/v1
sbz.merchant.url=http://192.168.16.29:24050/v1
# sbz.merchant.url=https://localhost:24000/v1
# sbz.merchant.url=https://api.stewardpay.co.zw/lab/v2
sbz.aggregator.url=${sbz.merchant.url}
sbz.aggregator.client-id=steward_pay
sbz.aggregator.client-secret=XjkbBAJeJCnAplYDTbp5xOjeyySVnYGp
sbz.aggregator.phone=263773591219
sbz.aggregator.account=1046737845
sbz.aggregator.encryption-key=735470ce-8efb-4c0f-ba3f-7298f805ab33
steward.payment.processor.url=http://192.168.16.29:24050/v1
steward.payment.processor.url=${sbz.merchant.url}