diff --git a/src/main/java/zw/qantra/tm/configs/JacksonConfig.java b/src/main/java/zw/qantra/tm/configs/JacksonConfig.java
index f146914..58e68f2 100644
--- a/src/main/java/zw/qantra/tm/configs/JacksonConfig.java
+++ b/src/main/java/zw/qantra/tm/configs/JacksonConfig.java
@@ -1,24 +1,27 @@
package zw.qantra.tm.configs;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.joda.JodaModule;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import com.fasterxml.jackson.datatype.joda.JodaModule;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Primary;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
-@Configuration
+@Service
public class JacksonConfig {
-
- @Bean
- @Primary
- public ObjectMapper objectMapper() {
- ObjectMapper objectMapper = new ObjectMapper();
- objectMapper.registerModule(new JavaTimeModule());
- objectMapper.registerModule(new JodaModule());
- objectMapper.registerModule(new Jdk8Module());
+ @Autowired
+ private ObjectMapper nflowObjectMapper;
- return objectMapper;
+ @EventListener(ApplicationReadyEvent.class)
+ public void updateMapper() {
+ // It's crucial to create a new ObjectMapper instance here
+ // instead of modifying an autowired one.
+ nflowObjectMapper.registerModule(new JavaTimeModule());
+ nflowObjectMapper.registerModule(new JodaModule());
+ nflowObjectMapper.registerModule(new Jdk8Module());
+ nflowObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
}
diff --git a/src/main/java/zw/qantra/tm/configs/NflowConfiguration.java b/src/main/java/zw/qantra/tm/configs/NflowConfiguration.java
new file mode 100644
index 0000000..08d9ab0
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/configs/NflowConfiguration.java
@@ -0,0 +1,79 @@
+package zw.qantra.tm.configs;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.joda.JodaModule;
+import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
+import io.nflow.engine.config.EngineConfiguration;
+import io.nflow.engine.config.NFlow;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import java.util.List;
+
+@Configuration
+public class NflowConfiguration {
+
+ /**
+ * Creates the primary, application-wide ObjectMapper.
+ *
+ * This bean is marked as {@code @Primary} to ensure it's the default for dependency injection
+ * throughout the application. It's configured using Spring Boot's {@link Jackson2ObjectMapperBuilder},
+ * which automatically registers modules for Java 8 Time, Optional, etc., if they are on the classpath.
+ * It also includes any custom {@link Module} beans defined in the application context.
+ *
+ *
+ * @param builder The default Jackson2ObjectMapperBuilder, pre-configured by Spring Boot.
+ * @param customModules A list of all custom Jackson {@link Module} beans found in the application context.
+ * @return A customized {@link com.fasterxml.jackson.databind.ObjectMapper}.
+ */
+ @Primary
+ @Bean
+ public ObjectMapper primaryObjectMapper(Jackson2ObjectMapperBuilder builder, List customModules) {
+ builder.modules(customModules);
+ builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ return builder.build();
+ }
+
+ /**
+ * Overrides the default ObjectMapper used by the nFlow REST API.
+ *
+ * This bean definition ensures that the nFlow REST endpoints use the same consistent,
+ * primary ObjectMapper as the rest of the application.
+ *
+ */
+ @Bean("nflowRestObjectMapper")
+ public ObjectMapper nflowRestObjectMapper(ObjectMapper primaryObjectMapper) {
+ return primaryObjectMapper;
+ }
+
+ /**
+ * Overrides the default ObjectMapper used by the nFlow Engine for variable serialization.
+ *
+ * This is the key fix for `InvalidDefinitionException` with `LocalDateTime` in workflow variables.
+ * It ensures the engine uses our fully configured primary ObjectMapper.
+ *
+ */
+ @Bean("nflowEngineObjectMapper")
+ public ObjectMapper nflowEngineObjectMapper(ObjectMapper primaryObjectMapper) {
+ return primaryObjectMapper;
+ }
+
+ @Bean
+ @NFlow
+ public EngineConfiguration.EngineObjectMapperSupplier nflowObjectMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
+ mapper.registerModule(new JodaModule());
+ mapper.registerModule(new Jdk8Module());
+ mapper.registerModule(new JSR310Module());
+ mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ return () -> mapper;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java
index 3afa522..ef9cb60 100644
--- a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java
+++ b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java
@@ -29,6 +29,7 @@ public class SecurityConfig {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
+ .requestMatchers("/configs/seed/**").permitAll()
.requestMatchers("/test/**").permitAll()
.requestMatchers("/explorer/**").permitAll()
.requestMatchers("/nflow/**").permitAll()
diff --git a/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java b/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java
index 009ab66..404ecfd 100644
--- a/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java
+++ b/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java
@@ -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);
diff --git a/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java b/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java
index 82a1989..cbe3f2a 100644
--- a/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java
+++ b/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java
@@ -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> getAll(
@@ -64,26 +82,80 @@ public class TransactonController {
return ResponseEntity.ok(transactionService.findById(id));
}
- @GetMapping("/poll/{id}")
- public ResponseEntity poll(@PathVariable UUID id) {
- return ResponseEntity.ok(transactionService.poll(id));
+ @GetMapping("/poll/{workflowId}")
+ public ResponseEntity 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 future = workflowUtils
+ .waitForState(Long.parseLong(workflowId), new String[]{"done", "failed"},
+ 15000, 50);
+ Object response = future.get();
+
+ return ResponseEntity.ok(response);
}
@PostMapping("/request")
- public ResponseEntity request(@RequestBody Transaction transaction) {
+ public ResponseEntity 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 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 create(@RequestBody Transaction transaction) {
+ public ResponseEntity 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 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}")
diff --git a/src/main/java/zw/qantra/tm/domain/models/Transaction.java b/src/main/java/zw/qantra/tm/domain/models/Transaction.java
index bcb2e45..35eb3ba 100644
--- a/src/main/java/zw/qantra/tm/domain/models/Transaction.java
+++ b/src/main/java/zw/qantra/tm/domain/models/Transaction.java
@@ -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)
diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java
index 9cfc899..b51edf5 100644
--- a/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java
+++ b/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java
@@ -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);
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java b/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java
new file mode 100644
index 0000000..8db7953
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java
@@ -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;
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java b/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java
index 37315a6..4618510 100644
--- a/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java
+++ b/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java
@@ -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();
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/CalculateChargesHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/CalculateChargesHandler.java
new file mode 100644
index 0000000..76ff75a
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/CalculateChargesHandler.java
@@ -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 chargeList = chargeService.getChargeRepository().findAllByCurrency(transaction.getDebitCurrency());
+
+ List appropriateCharges = new ArrayList<>();
+ for (Charge charge : chargeList) {
+ boolean isPresent = false;
+ for (ChargeCondition chargeCondition: charge.getIncludes()){
+ List 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 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);
+ }
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java
new file mode 100644
index 0000000..2b4f5a4
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java
@@ -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;
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/ConfirmHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/ConfirmHandler.java
new file mode 100644
index 0000000..0ceb137
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/ConfirmHandler.java
@@ -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;
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java
new file mode 100644
index 0000000..07b7e53
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java
@@ -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;
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/HandlerInterface.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/HandlerInterface.java
new file mode 100644
index 0000000..9bc52e4
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/HandlerInterface.java
@@ -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;
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java
new file mode 100644
index 0000000..8841fe9
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java
@@ -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;
+ }
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/WorkflowHandlerFactory.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/WorkflowHandlerFactory.java
new file mode 100644
index 0000000..492e078
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/WorkflowHandlerFactory.java
@@ -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;
+ }
+
+}
diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/SalesInvoiceHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/SalesInvoiceHandler.java
new file mode 100644
index 0000000..97d92f2
--- /dev/null
+++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/SalesInvoiceHandler.java
@@ -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 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);
+ }
+}
diff --git a/src/main/resources/application-vusa.properties b/src/main/resources/application-vusa.properties
index a7af74a..b283d84 100644
--- a/src/main/resources/application-vusa.properties
+++ b/src/main/resources/application-vusa.properties
@@ -1,2 +1,12 @@
# sbz.merchant.url=http://localhost:24000/v1
sbz.merchant.url=https://api.stewardpay.co.zw/lab/v2
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/qpay?useLegacyDatetimeCode=false
+spring.datasource.username=postgres
+spring.datasource.password=example
+
+
+nflow.db.postgresql.driver=org.postgresql.Driver
+nflow.db.postgresql.url=jdbc:postgresql://localhost:5432/qpay
+nflow.db.postgresql.user=postgres
+nflow.db.postgresql.password=example
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 93d5e33..bb0365b 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,4 +1,5 @@
spring.application.name=tm
+spring.main.allow-bean-definition-overriding=true
debug=false
logging.level.org.springframework.boot.autoconfigure=INFO