This commit is contained in:
2025-10-21 16:40:10 +02:00
parent 5753c82384
commit 2b6ef3ab14
19 changed files with 1020 additions and 28 deletions

View File

@@ -66,6 +66,7 @@ public class OnboardingController {
.setType(RegistrationWorkflow.TYPE)
.setExternalId(request.getUsername() + "-" + new Instant().getMillis())
.putStateVariable("body", request)
.setNextActivation(DateTime.now())
.build();
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);

View File

@@ -1,10 +1,18 @@
package zw.qantra.tm.domain.controllers;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.nflow.engine.internal.workflow.ObjectStringMapper;
import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
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.joda.time.DateTime;
import org.joda.time.Instant;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -15,9 +23,15 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.workflows.TransactionWorkflow;
import zw.qantra.tm.domain.workflows.WorkflowUtils;
import zw.qantra.tm.utils.LogUtils;
import zw.qantra.tm.utils.Utils;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@RestController
@RequestMapping("/public/transaction")
@@ -26,6 +40,10 @@ public class TransactonController {
Logger logger = LoggerFactory.getLogger(TransactonController.class);
private final TransactionService transactionService;
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowInstanceFactory workflowInstanceFactory;
private final WorkflowUtils workflowUtils;
private final ObjectStringMapper objectStringMapper;
@GetMapping
public ResponseEntity<Page<Transaction>> getAll(
@@ -64,26 +82,80 @@ public class TransactonController {
return ResponseEntity.ok(transactionService.findById(id));
}
@GetMapping("/poll/{id}")
public ResponseEntity<Transaction> poll(@PathVariable UUID id) {
return ResponseEntity.ok(transactionService.poll(id));
@GetMapping("/poll/{workflowId}")
public ResponseEntity<Object> poll(@PathVariable String workflowId) throws ExecutionException, InterruptedException {
// Create an update to move the workflow to the next state
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(Long.parseLong(workflowId))
.setState("pollStatus") // The state you want to transition to
.setNextActivation(DateTime.now()) // Schedule immediate execution
.setStateText("Resumed from manual state")
.build();
WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
// Apply the update
workflowInstanceService.updateWorkflowInstance(update, action);
CompletableFuture<Object> future = workflowUtils
.waitForState(Long.parseLong(workflowId), new String[]{"done", "failed"},
15000, 50);
Object response = future.get();
return ResponseEntity.ok(response);
}
@PostMapping("/request")
public ResponseEntity<Transaction> request(@RequestBody Transaction transaction) {
public ResponseEntity<Object> request(@RequestBody Transaction transaction) throws ExecutionException, InterruptedException {
LogUtils.logPrettyJson(logger, "incoming transaction", transaction);
Transaction createdTransaction = transactionService.request(transaction);
LogUtils.logPrettyJson(logger, "outgoing transaction", createdTransaction);
return ResponseEntity.ok(createdTransaction);
// Create an update to move the workflow to the next state
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(transaction.getWorkflowId())
.setState("salesInvoice") // The state you want to transition to
.setNextActivation(DateTime.now()) // Schedule immediate execution
.setStateVariables(new HashMap<>(){{ put("body", Utils.toJson(transaction)); }})
.setStateText("Resumed from manual state")
.build();
WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
// Apply the update
workflowInstanceService.updateWorkflowInstance(update, action);
CompletableFuture<Object> future = workflowUtils
.waitForState(transaction.getWorkflowId(), new String[]{"gatewayPaymentSuccess", "failed"},
15000, 50);
Object response = future.get();
LogUtils.logPrettyJson(logger, "outgoing transaction", response);
return ResponseEntity.ok(response);
}
@PostMapping
public ResponseEntity<Transaction> create(@RequestBody Transaction transaction) {
public ResponseEntity<Object> confirm(@RequestBody Transaction transaction) throws ExecutionException, InterruptedException {
LogUtils.logPrettyJson(logger, "incoming transaction", transaction);
Transaction createdTransaction = transactionService.confirm(transaction);
LogUtils.logPrettyJson(logger, "outgoing transaction", createdTransaction);
return ResponseEntity.ok(createdTransaction);
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(TransactionWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis())
.putStateVariable("incoming", transaction)
.setNextActivation(DateTime.now())
.build();
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
CompletableFuture<Object> future = workflowUtils
.waitForState(workflowId, new String[]{"confirmSuccess", "failed"},
15000, 50);
Object response = future.get();
LogUtils.logPrettyJson(logger, "outgoing transaction", response);
return ResponseEntity.ok(response);
}
@PutMapping("/{id}")

View File

@@ -80,6 +80,9 @@ public class Transaction extends BaseEntity {
private String erpPurchasePaymentRef;
private String erpCommissionJournalRef;
@Column(nullable = true)
private long workflowId;
@Enumerated(EnumType.STRING)
private RequestType type;
@Enumerated(EnumType.STRING)

View File

@@ -152,7 +152,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
transaction.setTargetUrl((String) body.get("targetUrl"));
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
transaction.setDebitRef((String) transactionData.get("creditReference"));
transaction.setDebitRef((String) transactionData.get("reference"));
transactionService.save(transaction);

View File

@@ -0,0 +1,177 @@
package zw.qantra.tm.domain.workflows;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.nflow.engine.workflow.curated.State;
import io.nflow.engine.workflow.definition.NextAction;
import io.nflow.engine.workflow.definition.StateExecution;
import io.nflow.engine.workflow.definition.WorkflowDefinition;
import io.nflow.engine.workflow.definition.WorkflowStateType;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.workflows.handlers.HandlerInterface;
import zw.qantra.tm.domain.workflows.handlers.WorkflowHandlerFactory;
import zw.qantra.tm.utils.Utils;
@Component
public class TransactionWorkflow extends WorkflowDefinition {
Logger log = org.slf4j.LoggerFactory.getLogger(RegistrationWorkflow.class);
@Autowired
private WorkflowHandlerFactory workflowHandlerFactory;
@Autowired
private TransactionService transactionService;
public static final String TYPE = "transactionWorkflow";
public static final State CALCULATE_CHARGES = new State("calculateCharges", WorkflowStateType.start);
public static final State CLEANUP = new State("cleanup");
public static final State CONFIRM = new State("confirm");
public static final State CONFIRM_SUCCESS = new State("confirmSuccess", WorkflowStateType.manual);
public static final State SALES_INVOICE = new State("salesInvoice");
public static final State GATEWAY_PAYMENT = new State("gatewayPayment");
public static final State GATEWAY_PAYMENT_SUCCESS = new State("gatewayPaymentSuccess", WorkflowStateType.manual);
public static final State POLL_STATUS = new State("pollStatus");
public static final State SALES_PAYMENT = new State("salesPayment");
public static final State PURCHASE_INVOICE = new State("purchaseInvoice");
public static final State BILL_PAYMENT = new State("billPayment");
public static final State PURCHASE_INVOICE_PAYMENT = new State("purchaseInvoicePayment");
public static final State FAILED = new State("failed", WorkflowStateType.manual);
public static final State DONE = new State("done", WorkflowStateType.end);
public TransactionWorkflow() {
super(TYPE, CALCULATE_CHARGES, FAILED);
permit(CALCULATE_CHARGES, CLEANUP);
permit(CLEANUP, CONFIRM);
permit(CONFIRM, CONFIRM_SUCCESS, FAILED);
permit(CONFIRM_SUCCESS, SALES_INVOICE);
permit(SALES_INVOICE, GATEWAY_PAYMENT);
permit(GATEWAY_PAYMENT, GATEWAY_PAYMENT_SUCCESS, FAILED);
permit(GATEWAY_PAYMENT_SUCCESS, POLL_STATUS);
permit(POLL_STATUS, SALES_PAYMENT, FAILED);
permit(SALES_PAYMENT, PURCHASE_INVOICE);
permit(PURCHASE_INVOICE, BILL_PAYMENT);
permit(BILL_PAYMENT, PURCHASE_INVOICE_PAYMENT, FAILED);
permit(PURCHASE_INVOICE_PAYMENT, DONE);
}
public NextAction calculateCharges(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("incoming", Transaction.class);
process(transaction, "calculateCharges");
execution.setVariable("body", transaction);
return NextAction.moveToState(CLEANUP, "Charge calculation complete");
} catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public NextAction cleanup(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("body", Transaction.class);
process(transaction, "cleanup");
execution.setVariable("body", transaction);
return NextAction.moveToState(CONFIRM, "Cleanup complete");
} catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public NextAction confirm(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("body", Transaction.class);
transaction.setWorkflowId(execution.getWorkflowInstanceId());
process(transaction, "confirm");
execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(CONFIRM_SUCCESS, "Cleanup complete");
} catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public void confirmSuccess(StateExecution execution) {
log.info("Confirm successful");
}
public NextAction salesInvoice(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("body", Transaction.class);
process(transaction, "salesInvoice");
execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(GATEWAY_PAYMENT, "Sales invoice generated");
}catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public NextAction gatewayPayment(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("body", Transaction.class);
transaction = process(transaction, "gatewayPayment");
execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(GATEWAY_PAYMENT_SUCCESS, "Sales invoice generated");
} catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public void gatewayPaymentSuccess(StateExecution execution) {
log.info("Gateway payment successful");
}
public NextAction pollStatus(StateExecution execution) {
try {
Transaction transaction = execution.getVariable("body", Transaction.class);
transaction = process(transaction, "pollStatus");
execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(SALES_PAYMENT, "Polling complete");
} catch (Exception e) {
e.printStackTrace();
return NextAction.moveToState(FAILED, e.getMessage());
}
}
public NextAction salesPayment(StateExecution execution) {
return NextAction.moveToState(PURCHASE_INVOICE, "Sales invoice generated");
}
public NextAction purchaseInvoice(StateExecution execution) {
return NextAction.moveToState(BILL_PAYMENT, "Purchase invoice generated");
}
public NextAction billPayment(StateExecution execution) {
return NextAction.moveToState(PURCHASE_INVOICE_PAYMENT, "Bill payment successful");
}
public NextAction purchaseInvoicePayment(StateExecution execution) {
return NextAction.moveToState(DONE, "Purchase invoice payment successful");
}
public Transaction process(Transaction transaction, String beanName) {
HandlerInterface handlerInterface =
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler(beanName);
try {
transaction = (Transaction) handlerInterface.process(transaction);
}catch (Exception e){
e.printStackTrace();
e.getCause().printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
transactionService.getTransactionRepository().save(transaction);
}
return transaction;
}
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import io.nflow.engine.model.ModelObject;
import io.nflow.engine.service.WorkflowInstanceInclude.*;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import org.springframework.stereotype.Component;
@@ -55,12 +56,17 @@ public class WorkflowUtils {
}
public StateResponse getStateResponse(WorkflowInstance instance, String message){
WorkflowInstanceAction action = instance.actions.get(instance.actions.size()-1);
WorkflowInstanceAction action;
try {
action = instance.actions.get(instance.actions.size()-1);
}catch (IndexOutOfBoundsException exception){
action = null;
}
return StateResponse.builder()
.state(instance.state)
.status(instance.status.toString())
.body(Utils.fromJson(instance.getStateVariable("body"), Map.class))
.message(action.stateText)
.message(action != null ? action.stateText : null)
.workflowId(instance.id.toString())
.externalId(instance.externalId)
.build();

View File

@@ -0,0 +1,148 @@
package zw.qantra.tm.domain.workflows.handlers;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Charge;
import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.ChargeService;
import zw.qantra.tm.domain.services.processors.handlers.LogicHandler;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Service("calculateCharges")
@RequiredArgsConstructor
public class CalculateChargesHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(CalculateChargesHandler.class);
private final ChargeService chargeService;
public Transaction process(Transaction transaction) {
logger.info("processing charges");
transaction.setCharge(BigDecimal.ZERO);
transaction.setTax(BigDecimal.ZERO);
transaction.setGatewayCharge(BigDecimal.ZERO);
BigDecimal amountToCharge = transaction.getAmount();
BigDecimal chargeAmount = BigDecimal.ZERO;
List<Charge> chargeList = chargeService.getChargeRepository().findAllByCurrency(transaction.getDebitCurrency());
List<Charge> appropriateCharges = new ArrayList<>();
for (Charge charge : chargeList) {
boolean isPresent = false;
for (ChargeCondition chargeCondition: charge.getIncludes()){
List<String> conditionTypes = Arrays.asList(chargeCondition.getCodes().split(","));
switch (chargeCondition.getType()){
case GATEWAY:
isPresent = conditionTypes.contains(transaction.getPaymentProcessorLabel());
break;
case PROVIDER:
isPresent = conditionTypes.contains(transaction.getIntegrationProcessorLabel());
break;
}
if(isPresent) break; // we've found a matching condition, no need to check rest of conditions
}
// if we did not find a match, means this charge does not apply to this transaction
// also implies there is no need to check exemptions
if(!isPresent) continue;
boolean isExempt = false;
for (ChargeCondition chargeCondition: charge.getExcludes()){
List<String> conditionTypes = Arrays.asList(chargeCondition.getCodes().split(","));
switch (chargeCondition.getType()){
case GATEWAY:
isExempt = conditionTypes.contains(transaction.getPaymentProcessorLabel());
break;
case PROVIDER:
isExempt = conditionTypes.contains(transaction.getIntegrationProcessorLabel());
break;
}
if(isExempt) break; // we've found a matching condition, no need to check rest of conditions
}
if(isExempt) continue; // if we found a match, means this transaction is exempt from this charge
appropriateCharges.add(charge);
}
for (Charge charge : appropriateCharges) {
// if minimum amount chargeable is lower than the amount to charge, apply the min charge
if(charge.getMinimumAmount() != null) {
if(charge.getMinimumAmount().compareTo(amountToCharge) > 0) {
applyCharge(transaction, charge, charge.getMin());
continue;
}
}
// if maximum amount chargeable is higher than the amount to charge, apply the max charge
if(charge.getMaximumAmount() != null) {
if(charge.getMaximumAmount().compareTo(amountToCharge) < 0) {
applyCharge(transaction, charge, charge.getMax());
continue;
}
}
// calculate unrestricted charge amount
// if percentage rate is not present then it's a flat charge, else it's a percentage
if(charge.getPercentageRate() == null) {
if(charge.getFlat() == null)
continue;
chargeAmount = charge.getFlat();
}else {
BigDecimal fraction = charge.getPercentageRate().divide(
new BigDecimal(100), 4, RoundingMode.CEILING);
chargeAmount = amountToCharge.multiply(fraction)
.setScale(2, RoundingMode.CEILING);
// if it's a composite charge then add the flat component as well
if(charge.isComposite()) {
chargeAmount = chargeAmount.add(charge.getFlat())
.setScale(2, RoundingMode.CEILING);
}
}
// if minimum charge is set, check if the charge amount is less than the minimum
if(charge.getMin() != null) {
if(charge.getMin().compareTo(chargeAmount) > 0){
chargeAmount = charge.getMin();
}
}
// if maximum charge is set, check if the charge amount is more than the maximum
if(charge.getMax() != null) {
if(charge.getMax().compareTo(chargeAmount) < 0) {
chargeAmount = charge.getMax();
}
}
applyCharge(transaction, charge, chargeAmount);
}
// calculate total amount for ease of use later
transaction.setTotalAmount(
transaction.getAmount().add(
transaction.getCharge().add(
transaction.getTax().add(
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);
case GATEWAY_FEE -> transaction.setGatewayCharge(chargeAmount);
}
}
}

View File

@@ -0,0 +1,96 @@
package zw.qantra.tm.domain.workflows.handlers;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import lombok.RequiredArgsConstructor;
import org.aspectj.apache.bcel.generic.RET;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.ProviderService;
import zw.qantra.tm.domain.services.processors.handlers.ConfirmationLabelHandler;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
import java.util.UUID;
@Service("cleanup")
@RequiredArgsConstructor
public class CleanupHandler implements HandlerInterface {
private final Logger logger = LoggerFactory.getLogger(ConfirmationLabelHandler.class);
private final ProviderService providerService;
@Override
public Object process(Transaction transaction) throws Exception {
// MAKE SURE IMPORTANT FIELDS ARE SET
// =======================
if(transaction.getType().equals(RequestType.CONFIRM)) {
transaction.setTrace(Utils.getUid());
transaction.setReference(UUID.randomUUID().toString());
}
// UPDATE TRAN WITH CONFIRMATION HANDLER
// =======================
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.getLabel();
transaction.setConfirmationProcessorLabel(label);
logger.info("label: {}", label);
// FORMAT FIELDS CORRECTLY
// =======================
// 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.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.PhoneNumber zimPhoneNumber = phoneUtil.parse(creditPhoneNumber, "ZW");
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,56 @@
package zw.qantra.tm.domain.workflows.handlers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.TransactionEvent;
import zw.qantra.tm.domain.services.TransactionEventService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.domain.services.processors.handlers.*;
@Service("confirm")
@RequiredArgsConstructor
public class ConfirmHandler implements HandlerInterface {
private final TransactionEventService transactionEventService;
private final PaymentProcessorFactory paymentProcessorFactory;
private final TransactionService transactionService;
@Override
public Object process(Transaction transaction) throws Exception {
transactionService.save(transaction);
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(transaction.getType().name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(
transaction.getConfirmationProcessorLabel());
try {
// MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction);
transaction.setConfirmationStatus(transaction.getStatus());
}catch (Exception e){
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
transactionService.save(transaction);
return transaction;
}
transactionEvent.setStatus(transaction.getStatus());
transactionEvent.setMessage(transaction.getErrorMessage());
transactionEventService.save(transactionEvent);
return transaction;
}
}

View File

@@ -0,0 +1,76 @@
package zw.qantra.tm.domain.workflows.handlers;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.TransactionEvent;
import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.domain.services.processors.handlers.*;
import zw.qantra.tm.domain.services.processors.handlers.CalculateChargesHandler;
import zw.qantra.tm.domain.services.processors.handlers.erp.*;
import zw.qantra.tm.exceptions.ApiException;
@Service("gatewayPayment")
@RequiredArgsConstructor
public class GatewayPaymentHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(TransactionService.class);
private final PaymentProcessorFactory paymentProcessorFactory;
private final ChargeService chargeService;
private final RecipientService recipientService;
private final TransactionEventService transactionEventService;
private final SalesInvoiceHandler salesInvoiceHandler;
private final TransactionService transactionService;
@Override
public Object process(Transaction incomingTransaction) throws Exception {
Transaction transaction = incomingTransaction;
if(incomingTransaction.getId() != null){
transaction = transactionService.getTransactionRepository().findById(incomingTransaction.getId()).orElseThrow(() ->
new ApiException("Transaction not found for ID: " + incomingTransaction.getId()));
transaction.setType(incomingTransaction.getType()); // type should be REQUEST here
transaction.setAuthType(incomingTransaction.getAuthType());
}
transactionService.save(transaction);
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(transaction.getType().name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
String label = transaction.getType() + "_" + transaction.getPaymentProcessorLabel() + "_" + transaction.getAuthType();
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
try {
// MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction);
transaction.setPaymentStatus(transaction.getStatus());
}catch (Exception e){
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
transactionService.save(transaction);
return transaction;
}
transactionEvent.setStatus(transaction.getStatus());
transactionEvent.setMessage(transaction.getErrorMessage());
transactionEventService.save(transactionEvent);
return transaction;
}
}

View File

@@ -0,0 +1,7 @@
package zw.qantra.tm.domain.workflows.handlers;
import zw.qantra.tm.domain.models.Transaction;
public interface HandlerInterface {
Object process(Transaction transaction) throws Exception;
}

View File

@@ -0,0 +1,72 @@
package zw.qantra.tm.domain.workflows.handlers;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.Transaction;
import zw.qantra.tm.domain.models.TransactionEvent;
import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.domain.services.processors.handlers.erp.*;
import zw.qantra.tm.exceptions.ApiException;
@Service("pollStatus")
@RequiredArgsConstructor
public class PollStatusHandler implements HandlerInterface {
private final PaymentProcessorFactory paymentProcessorFactory;
private final TransactionEventService transactionEventService;
private final TransactionService transactionService;
@Override
public Object process(Transaction transaction) throws Exception {
Logger logger = LoggerFactory.getLogger(TransactionService.class);
// if the transaction is already successful, return it
if(transaction.getIntegrationStatus() == Status.SUCCESS){
return transaction;
}
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.POLL.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
// no persistence happens during polling unless the transaction status changes
String label = "REQUEST_" + transaction.getPaymentProcessorLabel() + "_" + transaction.getAuthType();
logger.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
try {
// find out if the gateway tran was successful
transactionProcessorInterface.poll(transaction);
transactionEvent.setStatus(transaction.getStatus());
transactionEvent.setMessage(transaction.getErrorMessage());
transactionEventService.save(transactionEvent);
transaction.setPollingStatus(transaction.getStatus());
transactionService.save(transaction);
// if transaction wasn't successful, throw and halt workflow
if(transaction.getStatus() != Status.SUCCESS){
throw new ApiException("Transaction failed: " + transaction.getErrorMessage() );
}
} catch (Exception e) {
e.printStackTrace();
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
transaction.setErrorMessage(e.getMessage());
}
return transaction;
}
}

View File

@@ -0,0 +1,43 @@
package zw.qantra.tm.domain.workflows.handlers;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import zw.qantra.tm.exceptions.ApiException;
@Component
@RequiredArgsConstructor
public class WorkflowHandlerFactory implements ApplicationContextAware {
private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
public Object getWorkflowHandler(String beanName) {
Object handler;
try {
handler = applicationContext.getBean(beanName);
} catch (NoSuchBeanDefinitionException exception) {
exception.printStackTrace();
logger.error("Couldn't find handler: {}", exception.getMessage());
throw new ApiException("Couldn't find handler");
} catch (BeansException e) {
e.printStackTrace();
logger.error("Error retrieving handler bean: {}", e.getMessage());
throw new ApiException("Error retrieving handler bean");
}
return handler;
}
}

View File

@@ -0,0 +1,141 @@
package zw.qantra.tm.domain.workflows.handlers.erp;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.erp.SalesInvoiceDto;
import zw.qantra.tm.domain.dtos.erp.SalesInvoiceItemDto;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.TransactionEvent;
import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import zw.qantra.tm.domain.services.TransactionEventService;
import zw.qantra.tm.domain.services.UserService;
import zw.qantra.tm.domain.services.processors.handlers.LogicHandler;
import zw.qantra.tm.domain.workflows.handlers.HandlerInterface;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.rest.RestService;
import java.nio.charset.StandardCharsets;
import java.util.*;
@Service("salesInvoice")
@RequiredArgsConstructor
public class SalesInvoiceHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(SalesInvoiceHandler.class);
private final RestService restService;
private final UserService userService;
private final TransactionEventService transactionEventService;
private final TransactionRepository transactionRepository;
@Value("${erpnext.url}")
private String erpnextUrl;
@Value("${erpnext.api.key}")
private String erpnextApiKey;
@Value("${erpnext.api.secret}")
private String erpnextApiSecret;
@Value("${erpnext.username}")
private String erpnextUsername;
@Value("${erpnext.password}")
private String erpnextPassword;
@Value("${erpnext.company}")
private String erpnextCompany;
@Override
public Transaction process(Transaction transaction) {
logger.info("Processing sales invoice for transaction: {}", transaction.getReference());
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.SALES_INVOICE.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
if(transaction.getStatus() == Status.PENDING){
try {
logger.info("Creating sales invoice in ERPNext for successful transaction");
// login to erp
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
headers.add("Accept", "application/json");
Map<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
User user = userService.getUserRepository().findById(UUID.fromString(transaction.getUserId())).orElse(null);
if(user == null)
throw new ApiException("Could not find user");
SalesInvoiceDto salesInvoiceDto = SalesInvoiceDto.builder()
.doctype("Sales Invoice")
.customer(user.getErpCustomerRef())
.postingDate(transaction.getCreatedAt().toLocalDate().toString())
.dueDate(transaction.getCreatedAt().toLocalDate().toString())
.company(erpnextCompany)
.docstatus(1)
.items(Arrays.asList(SalesInvoiceItemDto.builder()
.itemCode(transaction.getProviderLabel())
.qty(1)
.rate(transaction.getAmount().add(transaction.getGatewayCharge()))
.amount(transaction.getAmount().add(transaction.getGatewayCharge()))
.build()))
.build();
try {
LinkedHashMap response = restService.postAs(
erpnextUrl + "/v2/document/Sales Invoice", salesInvoiceDto, headers, LinkedHashMap.class);
LinkedHashMap data = (LinkedHashMap) response.get("data");
String transactionId = (String) data.get("name");
transaction.setErpSalesRef(transactionId);
transactionEvent.setStatus(Status.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to create sales invoice in ERPNext");
}
logger.info("Sales invoice created successfully in ERPNext");
} catch (Exception e) {
logger.error("Failed to create sales invoice in ERPNext: {}", e.getMessage(), e);
// Don't fail the transaction, just log the error
// The ERP invoice creation is a side effect, not critical to the payment flow
transactionEvent.setStatus(Status.FAILED);
transactionEvent.setMessage(e.getMessage());
}
} else {
logger.info("Transaction status is not SUCCESS ({}), skipping sales invoice creation", transaction.getStatus());
}
transactionEventService.save(transactionEvent);
return transactionRepository.save(transaction);
}
}