From 2b6ef3ab14f90a2c46135baf4f845757dbef285a Mon Sep 17 00:00:00 2001 From: Vusumuzi Khoza Date: Tue, 21 Oct 2025 16:40:10 +0200 Subject: [PATCH] progress --- .../zw/qantra/tm/configs/JacksonConfig.java | 31 +-- .../qantra/tm/configs/NflowConfiguration.java | 79 ++++++++ .../zw/qantra/tm/configs/SecurityConfig.java | 1 + .../controllers/OnboardingController.java | 1 + .../controllers/TransactonController.java | 94 ++++++++-- .../qantra/tm/domain/models/Transaction.java | 3 + .../payments/MPGSWebPaymentProcessor.java | 2 +- .../domain/workflows/TransactionWorkflow.java | 177 ++++++++++++++++++ .../tm/domain/workflows/WorkflowUtils.java | 10 +- .../handlers/CalculateChargesHandler.java | 148 +++++++++++++++ .../workflows/handlers/CleanupHandler.java | 96 ++++++++++ .../workflows/handlers/ConfirmHandler.java | 56 ++++++ .../handlers/GatewayPaymentHandler.java | 76 ++++++++ .../workflows/handlers/HandlerInterface.java | 7 + .../workflows/handlers/PollStatusHandler.java | 72 +++++++ .../handlers/WorkflowHandlerFactory.java | 43 +++++ .../handlers/erp/SalesInvoiceHandler.java | 141 ++++++++++++++ .../resources/application-vusa.properties | 10 + src/main/resources/application.properties | 1 + 19 files changed, 1020 insertions(+), 28 deletions(-) create mode 100644 src/main/java/zw/qantra/tm/configs/NflowConfiguration.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/CalculateChargesHandler.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/ConfirmHandler.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/HandlerInterface.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/WorkflowHandlerFactory.java create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/SalesInvoiceHandler.java 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