From 2b6ef3ab14f90a2c46135baf4f845757dbef285a Mon Sep 17 00:00:00 2001 From: Vusumuzi Khoza Date: Tue, 21 Oct 2025 16:40:10 +0200 Subject: [PATCH 1/3] 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 From 77a6dd69a6de3b04cf2a4259c676d231c563b950 Mon Sep 17 00:00:00 2001 From: Vusumuzi Khoza Date: Tue, 21 Oct 2025 23:47:15 +0200 Subject: [PATCH 2/3] completed transaction workflow --- .../controllers/TransactonController.java | 27 ++- .../domain/services/TransactionService.java | 222 ------------------ .../domain/workflows/TransactionWorkflow.java | 197 +++++++++++----- .../workflows/handlers/CleanupHandler.java | 1 + .../handlers/IntegrationHandler.java | 71 ++++++ ...aymentHandler.java => PaymentHandler.java} | 7 +- .../workflows/handlers/PollStatusHandler.java | 5 +- .../handlers/erp/JournalEntryHandler.java | 15 +- .../handlers/erp/PaymentEntryHandler.java | 17 +- .../handlers/erp/PurchaseInvoiceHandler.java | 17 +- .../erp/PurchasePaymentEntryHandler.java} | 90 ++++--- .../handlers/erp/SalesInvoiceHandler.java | 145 ++++++------ 12 files changed, 384 insertions(+), 430 deletions(-) create mode 100644 src/main/java/zw/qantra/tm/domain/workflows/handlers/IntegrationHandler.java rename src/main/java/zw/qantra/tm/domain/workflows/handlers/{GatewayPaymentHandler.java => PaymentHandler.java} (90%) rename src/main/java/zw/qantra/tm/domain/{services/processors => workflows}/handlers/erp/JournalEntryHandler.java (91%) rename src/main/java/zw/qantra/tm/domain/{services/processors => workflows}/handlers/erp/PaymentEntryHandler.java (92%) rename src/main/java/zw/qantra/tm/domain/{services/processors => workflows}/handlers/erp/PurchaseInvoiceHandler.java (93%) rename src/main/java/zw/qantra/tm/domain/{services/processors/handlers/erp/SalesInvoiceHandler.java => workflows/handlers/erp/PurchasePaymentEntryHandler.java} (55%) 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 cbe3f2a..871c8e7 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java @@ -16,6 +16,8 @@ import org.joda.time.Instant; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import zw.qantra.tm.domain.enums.AuthType; +import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.models.Transaction; @@ -82,11 +84,12 @@ public class TransactonController { return ResponseEntity.ok(transactionService.findById(id)); } - @GetMapping("/poll/{workflowId}") - public ResponseEntity poll(@PathVariable String workflowId) throws ExecutionException, InterruptedException { + @GetMapping("/poll/{id}") + public ResponseEntity poll(@PathVariable UUID id) throws ExecutionException, InterruptedException { + Transaction transaction = transactionService.findById(id); // Create an update to move the workflow to the next state WorkflowInstance update = new WorkflowInstance.Builder() - .setId(Long.parseLong(workflowId)) + .setId(transaction.getWorkflowId()) .setState("pollStatus") // The state you want to transition to .setNextActivation(DateTime.now()) // Schedule immediate execution .setStateText("Resumed from manual state") @@ -101,22 +104,28 @@ public class TransactonController { workflowInstanceService.updateWorkflowInstance(update, action); CompletableFuture future = workflowUtils - .waitForState(Long.parseLong(workflowId), new String[]{"done", "failed"}, + .waitForState(transaction.getWorkflowId(), new String[]{"done", "failed"}, 15000, 50); Object response = future.get(); return ResponseEntity.ok(response); } - @PostMapping("/request") - public ResponseEntity request(@RequestBody Transaction transaction) throws ExecutionException, InterruptedException { - LogUtils.logPrettyJson(logger, "incoming transaction", transaction); + @GetMapping("/request/{id}/{authType}") + public ResponseEntity request(@PathVariable UUID id, @PathVariable AuthType authType) + throws ExecutionException, InterruptedException { + LogUtils.logPrettyJson(logger, "incoming transaction", id + " - " + authType); + Transaction transaction = transactionService.findById(id); + transaction.setType(RequestType.REQUEST); + transaction.setAuthType(authType); + transactionService.save(transaction); + // 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)); }}) + .setStateVariables(new HashMap<>(){{ put("request", Utils.toJson(transaction)); }}) .setStateText("Resumed from manual state") .build(); @@ -144,7 +153,7 @@ public class TransactonController { var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() .setType(TransactionWorkflow.TYPE) .setExternalId(transaction.getUserId() + "-" + new Instant().getMillis()) - .putStateVariable("incoming", transaction) + .putStateVariable("confirmation", transaction) .setNextActivation(DateTime.now()) .build(); diff --git a/src/main/java/zw/qantra/tm/domain/services/TransactionService.java b/src/main/java/zw/qantra/tm/domain/services/TransactionService.java index 8021590..53b85d6 100644 --- a/src/main/java/zw/qantra/tm/domain/services/TransactionService.java +++ b/src/main/java/zw/qantra/tm/domain/services/TransactionService.java @@ -40,230 +40,8 @@ public class TransactionService { @Getter private final TransactionRepository transactionRepository; - private final PaymentProcessorFactory paymentProcessorFactory; - private final ProviderService providerService; - private final ChargeService chargeService; private final AdditionalDataService additionalDataService; - private final RecipientService recipientService; - private final TransactionEventService transactionEventService; - private final SalesInvoiceHandler salesInvoiceHandler; - private final JournalEntryHandler journalEntryHandler; - private final PaymentEntryHandler paymentEntryHandler; - private final PurchaseInvoiceHandler purchaseInvoiceHandler; - private final PurchasePaymentEntryHandler purchasePaymentEntryHandler; - - public Transaction integration(Transaction transaction) { - transaction.setType(RequestType.INTEGRATION); - - TransactionEvent transactionEvent = TransactionEvent.builder() - .transactionId(transaction.getId().toString()) - .event(transaction.getType().name()) - .status(Status.PROCESSING) - .build(); - transactionEventService.save(transactionEvent); - - LogicPipeline transactionLogic = - new LogicPipeline<>(new ValidationHandler()) - .addHandler(journalEntryHandler) // todo: do this in a separate thread - .addHandler(paymentEntryHandler); // todo: do this in a separate thread - transaction = transactionLogic.execute(transaction); - - Provider provider = providerService.getProviderRepository() - .findByClientId(transaction.getBillClientId()); - if(provider == null){ - throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId()); - } - String label = transaction.getType() + "_" + - provider.getIntegrationProcessorLabel(); - logger.info("label: {}", label); - transaction.setIntegrationProcessorLabel(provider.getIntegrationProcessorLabel()); // for reference purposes - - TransactionProcessorInterface transactionProcessorInterface = - (TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label); - - try { - transactionProcessorInterface.process(transaction); - transaction.setIntegrationStatus(transaction.getStatus()); - transactionRepository.save(transaction); - }catch (Exception e){ - e.getCause().printStackTrace(); - transaction.setStatus(Status.FAILED); - transaction.setResponseCode("01"); - transaction.setErrorMessage(e.getMessage()); - transactionRepository.save(transaction); - return transaction; - } - - // run post integration logic - LogicPipeline postTransactionLogic = - new LogicPipeline<>(purchaseInvoiceHandler) // todo: do this in a separate thread - .addHandler(purchasePaymentEntryHandler); // todo: do this in a separate thread - transaction = postTransactionLogic.execute(transaction); - - transactionEvent.setStatus(transaction.getStatus()); - transactionEvent.setMessage(transaction.getErrorMessage()); - transactionEventService.save(transactionEvent); - - return transaction; - } - - public Transaction poll(UUID uuid){ - if(!transactionRepository.existsById(uuid)){ - throw new ApiException("Transaction not found for ID: " + uuid); - } - - Transaction transaction = transactionRepository.findById(uuid).get(); - // 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()); - transactionRepository.save(transaction); - - // if tran was successful push integration to the aggregator - if(transaction.getStatus() == Status.SUCCESS){ - transaction.setType(RequestType.INTEGRATION); - integration(transaction); - } - } catch (Exception e) { - e.printStackTrace(); - transaction.setStatus(Status.FAILED); - transaction.setResponseCode("01"); - transaction.setErrorMessage(e.getMessage()); - } - - return transaction; - } - - public Transaction request(Transaction incomingTransaction) { - // if payment request (not confirm), then fetch already existing transaction record - Transaction transaction = incomingTransaction; - if(incomingTransaction.getId() != null){ - transaction = 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()); - } - - // run pre transaction logic - LogicPipeline preTransactionLogic = - new LogicPipeline<>(new CalculateChargesHandler(chargeService)) - .addHandler(new FormattingHandler()) - .addHandler(new ValidationHandler()); - transaction = preTransactionLogic.execute(transaction); - - transactionRepository.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()); - transactionRepository.save(transaction); - return transaction; - } - - // run post transaction logic - LogicPipeline postTransactionLogic = - new LogicPipeline<>(new RecipientsUpdateHandler(recipientService)) - .addHandler(salesInvoiceHandler);// todo: do this in a separate thread - transaction = postTransactionLogic.execute(transaction); - - transactionEvent.setStatus(transaction.getStatus()); - transactionEvent.setMessage(transaction.getErrorMessage()); - transactionEventService.save(transactionEvent); - - return transaction; - } - - public Transaction confirm(Transaction transaction) { - // run pre transaction logic - LogicPipeline preTransactionLogic = - new LogicPipeline<>(new CalculateChargesHandler(chargeService)) - .addHandler(new ConfirmationLabelHandler(providerService)) - .addHandler(new ConfirmationLogicHandler()) - .addHandler(new FormattingHandler()) - .addHandler(new ValidationHandler()); - transaction = preTransactionLogic.execute(transaction); - transactionRepository.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()); - transactionRepository.save(transaction); - return transaction; - } - - // run post transaction logic - LogicPipeline postTransactionLogic = - new LogicPipeline<>(new RecipientsUpdateHandler(recipientService)); - transaction = postTransactionLogic.execute(transaction); - - transactionEvent.setStatus(transaction.getStatus()); - transactionEvent.setMessage(transaction.getErrorMessage()); - transactionEventService.save(transactionEvent); - - return transaction; - } public Transaction findById(UUID id) { Transaction transaction = transactionRepository.findById(id).orElseThrow(() -> new ApiException("Transaction not found for ID: " + id)); diff --git a/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java b/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java index 8db7953..2c2e332 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/TransactionWorkflow.java @@ -6,6 +6,7 @@ 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.joda.time.DateTime; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -16,6 +17,8 @@ import zw.qantra.tm.domain.workflows.handlers.HandlerInterface; import zw.qantra.tm.domain.workflows.handlers.WorkflowHandlerFactory; import zw.qantra.tm.utils.Utils; +import java.util.LinkedHashMap; + @Component public class TransactionWorkflow extends WorkflowDefinition { Logger log = org.slf4j.LoggerFactory.getLogger(RegistrationWorkflow.class); @@ -24,6 +27,8 @@ public class TransactionWorkflow extends WorkflowDefinition { private WorkflowHandlerFactory workflowHandlerFactory; @Autowired private TransactionService transactionService; + @Autowired + private ObjectMapper objectMapper; public static final String TYPE = "transactionWorkflow"; @@ -34,10 +39,11 @@ public class TransactionWorkflow extends WorkflowDefinition { 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 JOURNAL_ENTRY = new State("journalEntry"); 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 INTEGRATION = new State("integration"); public static final State PURCHASE_INVOICE_PAYMENT = new State("purchaseInvoicePayment"); public static final State FAILED = new State("failed", WorkflowStateType.manual); @@ -53,21 +59,23 @@ public class TransactionWorkflow extends WorkflowDefinition { 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(POLL_STATUS, JOURNAL_ENTRY, FAILED); + permit(JOURNAL_ENTRY, SALES_PAYMENT); permit(SALES_PAYMENT, PURCHASE_INVOICE); - permit(PURCHASE_INVOICE, BILL_PAYMENT); - permit(BILL_PAYMENT, PURCHASE_INVOICE_PAYMENT, FAILED); + permit(PURCHASE_INVOICE, INTEGRATION); + permit(INTEGRATION, PURCHASE_INVOICE_PAYMENT, FAILED); permit(PURCHASE_INVOICE_PAYMENT, DONE); } public NextAction calculateCharges(StateExecution execution) { try { - Transaction transaction = execution.getVariable("incoming", Transaction.class); + Transaction transaction = execution.getVariable("confirmation", Transaction.class); process(transaction, "calculateCharges"); execution.setVariable("body", transaction); return NextAction.moveToState(CLEANUP, "Charge calculation complete"); } catch (Exception e) { + e.printStackTrace(); return NextAction.moveToState(FAILED, e.getMessage()); } } @@ -80,6 +88,7 @@ public class TransactionWorkflow extends WorkflowDefinition { return NextAction.moveToState(CONFIRM, "Cleanup complete"); } catch (Exception e) { + e.printStackTrace(); return NextAction.moveToState(FAILED, e.getMessage()); } } @@ -89,73 +98,149 @@ public class TransactionWorkflow extends WorkflowDefinition { Transaction transaction = execution.getVariable("body", Transaction.class); transaction.setWorkflowId(execution.getWorkflowInstanceId()); - process(transaction, "confirm"); + transaction = 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"); + return NextAction.moveToState(CONFIRM_SUCCESS, "Transaction confirmed successfully"); } catch (Exception e) { e.printStackTrace(); return NextAction.moveToState(FAILED, e.getMessage()); } } + public void confirmSuccess(StateExecution execution) { + log.info("Confirm complete"); + } + + public NextAction salesInvoice(StateExecution execution) { + try { + // cannot cast direct to Transaction object because of some jackson mapper hullabaloo :( + LinkedHashMap map = execution.getVariable("request", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "salesInvoice"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(GATEWAY_PAYMENT, + "Sales invoice generated - id:" + transaction.getErpSalesRef()); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); + } + } + + public NextAction gatewayPayment(StateExecution execution) { + try { + // cannot cast direct to Transaction object because of some jackson mapper hullabaloo :( + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "gatewayPayment"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(GATEWAY_PAYMENT_SUCCESS, "Gateway payment initiated"); + } catch (Exception e) { + e.printStackTrace(); + return NextAction.moveToState(FAILED, e.getMessage()); + } + } + + public void gatewayPaymentSuccess(StateExecution execution) { + log.info("Gateway payment initiation complete"); + } + + public NextAction pollStatus(StateExecution execution) { + try { + // cannot cast direct to Transaction object because of some jackson mapper hullabaloo :( + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "pollStatus"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(JOURNAL_ENTRY, "Polling complete"); + } catch (Exception e) { + e.printStackTrace(); + return NextAction.moveToState(FAILED, e.getMessage()); + } + } + + public NextAction journalEntry(StateExecution execution) { + try { + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "journalEntry"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(SALES_PAYMENT, + "Journal Entry successful - id: " + transaction.getErpJournalRef()); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); + } + } + public NextAction salesPayment(StateExecution execution) { - return NextAction.moveToState(PURCHASE_INVOICE, "Sales invoice generated"); + try { + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "salesPayment"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(PURCHASE_INVOICE, + "Sales payment successful - id: " + transaction.getErpSalesPaymentRef()); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); + } } public NextAction purchaseInvoice(StateExecution execution) { - return NextAction.moveToState(BILL_PAYMENT, "Purchase invoice generated"); + try { + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "purchaseInvoice"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(INTEGRATION, + "Purchase invoice generated - id: " + transaction.getErpPurchaseRef()); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); + } } - public NextAction billPayment(StateExecution execution) { - return NextAction.moveToState(PURCHASE_INVOICE_PAYMENT, "Bill payment successful"); + public NextAction integration(StateExecution execution) { + try { + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "integration"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(PURCHASE_INVOICE_PAYMENT, "Integration request successful"); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.moveToState(FAILED, e.getMessage()); + } } public NextAction purchaseInvoicePayment(StateExecution execution) { - return NextAction.moveToState(DONE, "Purchase invoice payment successful"); + try { + LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); + byte[] json = objectMapper.writeValueAsBytes(map); + Transaction transaction = objectMapper.readValue(json, Transaction.class); + + transaction = process(transaction, "purchaseInvoicePayment"); + execution.setVariable("body", Utils.toJson(transaction)); + return NextAction.moveToState(DONE, + "Purchase invoice payment generated - id: " + transaction.getErpPurchaseRef()); + }catch (Exception e) { + e.printStackTrace(); + return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); + } } public Transaction process(Transaction transaction, String beanName) { 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 index 2b4f5a4..0955e62 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/CleanupHandler.java @@ -44,6 +44,7 @@ public class CleanupHandler implements HandlerInterface { String label = transaction.getType() + "_" + provider.getLabel(); transaction.setConfirmationProcessorLabel(label); + transaction.setProviderLabel(provider.getLabel()); logger.info("label: {}", label); // FORMAT FIELDS CORRECTLY diff --git a/src/main/java/zw/qantra/tm/domain/workflows/handlers/IntegrationHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/IntegrationHandler.java new file mode 100644 index 0000000..afa84b4 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/IntegrationHandler.java @@ -0,0 +1,71 @@ +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.Provider; +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.LogicPipeline; +import zw.qantra.tm.domain.services.processors.handlers.ValidationHandler; +import zw.qantra.tm.exceptions.ApiException; + +@Service("integration") +@RequiredArgsConstructor +public class IntegrationHandler implements HandlerInterface { + Logger logger = LoggerFactory.getLogger(TransactionService.class); + + private final PaymentProcessorFactory paymentProcessorFactory; + private final ProviderService providerService; + private final RecipientService recipientService; + private final TransactionEventService transactionEventService; + private final TransactionService transactionService; + + @Override + public Object process(Transaction transaction) throws Exception { + transaction.setType(RequestType.INTEGRATION); + + TransactionEvent transactionEvent = TransactionEvent.builder() + .transactionId(transaction.getId().toString()) + .event(transaction.getType().name()) + .status(Status.PROCESSING) + .build(); + transactionEventService.save(transactionEvent); + + Provider provider = providerService.getProviderRepository() + .findByClientId(transaction.getBillClientId()); + if(provider == null){ + throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId()); + } + String label = transaction.getType() + "_" + + provider.getIntegrationProcessorLabel(); + logger.info("label: {}", label); + transaction.setIntegrationProcessorLabel(provider.getIntegrationProcessorLabel()); // for reference purposes + + TransactionProcessorInterface transactionProcessorInterface = + (TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label); + + try { + transactionProcessorInterface.process(transaction); + transaction.setIntegrationStatus(transaction.getStatus()); + }catch (Exception e){ + e.getCause().printStackTrace(); + transaction.setStatus(Status.FAILED); + transaction.setResponseCode("01"); + transaction.setErrorMessage(e.getMessage()); + } + + transactionEvent.setStatus(transaction.getStatus()); + transactionEvent.setMessage(transaction.getErrorMessage()); + transactionEventService.save(transactionEvent); + + transactionService.save(transaction); + 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/PaymentHandler.java similarity index 90% rename from src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java rename to src/main/java/zw/qantra/tm/domain/workflows/handlers/PaymentHandler.java index 07b7e53..b423faa 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/handlers/GatewayPaymentHandler.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/PaymentHandler.java @@ -10,22 +10,17 @@ 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 { +public class PaymentHandler 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 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 index 8841fe9..8c74349 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java @@ -54,11 +54,13 @@ public class PollStatusHandler implements HandlerInterface { 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() ); + }else { + transaction.setPaymentStatus(transaction.getStatus()); + transaction.setErrorMessage(null); } } catch (Exception e) { e.printStackTrace(); @@ -67,6 +69,7 @@ public class PollStatusHandler implements HandlerInterface { transaction.setErrorMessage(e.getMessage()); } + transactionService.save(transaction); return transaction; } } diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/JournalEntryHandler.java b/src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/JournalEntryHandler.java similarity index 91% rename from src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/JournalEntryHandler.java rename to src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/JournalEntryHandler.java index aaf42a9..76d0ffc 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/JournalEntryHandler.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/erp/JournalEntryHandler.java @@ -1,17 +1,14 @@ -package zw.qantra.tm.domain.services.processors.handlers.erp; +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.Autowired; 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.JournalEntryAccountDto; import zw.qantra.tm.domain.dtos.erp.JournalEntryDto; -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; @@ -19,18 +16,18 @@ 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.TransactionService; 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 +@Service("journalEntry") @RequiredArgsConstructor -public class JournalEntryHandler implements LogicHandler{ +public class JournalEntryHandler implements HandlerInterface { Logger logger = LoggerFactory.getLogger(JournalEntryHandler.class); private final RestService restService; @@ -100,10 +97,6 @@ public class JournalEntryHandler implements LogicHandler{ +public class PaymentEntryHandler implements HandlerInterface { Logger logger = LoggerFactory.getLogger(PaymentEntryHandler.class); private final RestService restService; @@ -95,9 +94,9 @@ public class PaymentEntryHandler implements LogicHandler{ +public class PurchaseInvoiceHandler implements HandlerInterface { Logger logger = LoggerFactory.getLogger(PurchaseInvoiceHandler.class); private final RestService restService; @@ -98,10 +99,6 @@ public class PurchaseInvoiceHandler implements LogicHandler{ - Logger logger = LoggerFactory.getLogger(SalesInvoiceHandler.class); +public class PurchasePaymentEntryHandler implements HandlerInterface { + Logger logger = LoggerFactory.getLogger(PurchasePaymentEntryHandler.class); private final RestService restService; private final UserService userService; private final TransactionEventService transactionEventService; private final TransactionRepository transactionRepository; + private final ProviderService providerService; @Value("${erpnext.url}") private String erpnextUrl; @@ -50,20 +52,26 @@ public class SalesInvoiceHandler implements LogicHandler loginRequest = Map.of( + "usr", erpnextUsername, + "pwd", erpnextPassword); + + String sid = ""; + try { - logger.info("Creating sales invoice in ERPNext for successful transaction"); + ResponseEntity response = restService.postAsEntity( + erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class); - // 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"); + sid = response.getHeaders().getFirst("Set-Cookie"); } 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()); + e.printStackTrace(); + throw new ApiException("Failed to login to ERPNext"); } - } else { - logger.info("Transaction status is not SUCCESS ({}), skipping sales invoice creation", transaction.getStatus()); + + // create customer in erp + String basicAuth = "Basic " + Base64.getEncoder().encodeToString( + (erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8)); + headers.add("Authorization", basicAuth); + headers.add("Cookie", sid); + + String erpCustomerRef = ""; + User user = userService.getUserRepository().findById(UUID.fromString(transaction.getUserId())).orElse(null); + erpCustomerRef = (user == null) ? "Anonymous" : user.getErpCustomerRef(); + + SalesInvoiceDto salesInvoiceDto = SalesInvoiceDto.builder() + .doctype("Sales Invoice") + .customer(erpCustomerRef) + .postingDate(new LocalDateTime().toLocalDate().toString()) + .dueDate(new LocalDateTime().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()); } transactionEventService.save(transactionEvent); - return transactionRepository.save(transaction); + return transactionService.save(transaction); } } From d7f8baad40a79b3e7926936134c8eda854e36050 Mon Sep 17 00:00:00 2001 From: Vusumuzi Khoza Date: Wed, 22 Oct 2025 00:19:52 +0200 Subject: [PATCH 3/3] minor adjustments --- .../domain/services/TransactionService.java | 13 -- .../erp/PurchasePaymentEntryHandler.java | 171 ------------------ .../workflows/handlers/PollStatusHandler.java | 1 - 3 files changed, 185 deletions(-) delete mode 100644 src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/PurchasePaymentEntryHandler.java diff --git a/src/main/java/zw/qantra/tm/domain/services/TransactionService.java b/src/main/java/zw/qantra/tm/domain/services/TransactionService.java index 53b85d6..84b0219 100644 --- a/src/main/java/zw/qantra/tm/domain/services/TransactionService.java +++ b/src/main/java/zw/qantra/tm/domain/services/TransactionService.java @@ -14,22 +14,9 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import zw.qantra.tm.domain.enums.RequestType; -import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.models.AdditionalData; -import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.repositories.TransactionRepository; -import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; -import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; -import zw.qantra.tm.domain.services.processors.handlers.CalculateChargesHandler; -import zw.qantra.tm.domain.services.processors.handlers.ConfirmationLabelHandler; -import zw.qantra.tm.domain.services.processors.handlers.ConfirmationLogicHandler; -import zw.qantra.tm.domain.services.processors.handlers.FormattingHandler; -import zw.qantra.tm.domain.services.processors.handlers.LogicPipeline; -import zw.qantra.tm.domain.services.processors.handlers.RecipientsUpdateHandler; -import zw.qantra.tm.domain.services.processors.handlers.ValidationHandler; -import zw.qantra.tm.domain.services.processors.handlers.erp.*; -import zw.qantra.tm.domain.models.TransactionEvent; import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.utils.Utils; diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/PurchasePaymentEntryHandler.java b/src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/PurchasePaymentEntryHandler.java deleted file mode 100644 index 5fcd0e5..0000000 --- a/src/main/java/zw/qantra/tm/domain/services/processors/handlers/erp/PurchasePaymentEntryHandler.java +++ /dev/null @@ -1,171 +0,0 @@ -package zw.qantra.tm.domain.services.processors.handlers.erp; - -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -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.PaymentEntryDto; -import zw.qantra.tm.domain.dtos.erp.PaymentReferenceDto; -import zw.qantra.tm.domain.enums.RequestType; -import zw.qantra.tm.domain.enums.Status; -import zw.qantra.tm.domain.models.Provider; -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.ProviderService; -import zw.qantra.tm.domain.services.TransactionEventService; -import zw.qantra.tm.domain.services.TransactionService; -import zw.qantra.tm.domain.services.UserService; -import zw.qantra.tm.domain.services.processors.handlers.LogicHandler; -import zw.qantra.tm.exceptions.ApiException; -import zw.qantra.tm.rest.RestService; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.nio.charset.StandardCharsets; -import java.util.*; - -@Service -@RequiredArgsConstructor -public class PurchasePaymentEntryHandler implements LogicHandler{ - Logger logger = LoggerFactory.getLogger(PurchasePaymentEntryHandler.class); - - private final RestService restService; - private final UserService userService; - private final TransactionEventService transactionEventService; - private final TransactionRepository transactionRepository; - private final ProviderService providerService; - - @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; - @Value("${erpnext.accounts.accountsreceivable}") - private String accountsReceivableAccount; - @Value("${erpnext.accounts.cash}") - private String cashAccount; - @Value("${erpnext.accounts.payable}") - private String payableAccount; - - @Override - public Transaction process(Transaction transaction) { - logger.info("Processing purchase payment entry for transaction: {}", transaction.getReference()); - TransactionEvent transactionEvent = TransactionEvent.builder() - .transactionId(transaction.getId().toString()) - .event(RequestType.PURCHASE_PAYMENT.name()) - .status(Status.PROCESSING) - .build(); - transactionEventService.save(transactionEvent); - - if(transaction.getStatus() == Status.SUCCESS){ - try { - logger.info("Creating purchase payment entry 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"); - - Provider provider = providerService.getProviderRepository() - .findByClientId(transaction.getBillClientId()); - BigDecimal commissionPercentage = BigDecimal.valueOf(provider.getPercentageCommission()) - .divide(BigDecimal.valueOf(100), 2, RoundingMode.CEILING); - BigDecimal commission = transaction.getAmount().multiply(commissionPercentage) - .setScale(2, RoundingMode.CEILING); - BigDecimal amount = transaction.getAmount().subtract(commission); - - PaymentReferenceDto paymentReferenceDto = PaymentReferenceDto.builder() - .referenceDoctype("Purchase Invoice") - .referenceName(transaction.getErpPurchaseRef()) - .allocatedAmount(amount) - .build(); - - String refNo = transaction.getCreditRef().isBlank() ? transaction.getId().toString() : transaction.getCreditRef(); - - PaymentEntryDto paymentEntryDto = PaymentEntryDto.builder() - .doctype("Payment Entry") - .company(erpnextCompany) - .docstatus(1) - .paymentType("Pay") - .party("Steward Bank") - .partyType("Supplier") - .paidFrom(cashAccount) - .paidTo(payableAccount) - .paidAmount(amount) - .receivedAmount(amount) - .referenceNo(refNo) - .referenceDate(transaction.getCreatedAt().toLocalDate().toString()) - .postingDate(transaction.getCreatedAt().toLocalDate().toString()) - .references(Arrays.asList(paymentReferenceDto)) - .build(); - - try { - LinkedHashMap response = restService.postAs( - erpnextUrl + "/v2/document/Payment Entry", paymentEntryDto, headers, LinkedHashMap.class); - - LinkedHashMap data = (LinkedHashMap) response.get("data"); - String transactionId = (String) data.get("name"); - - transaction.setErpPurchasePaymentRef(transactionId); - transactionEvent.setStatus(Status.SUCCESS); - } catch (Exception e) { - e.printStackTrace(); - throw new ApiException("Failed to create purchase payment entry in ERPNext"); - } - - logger.info("purchase payment entry created successfully in ERPNext"); - } catch (Exception e) { - logger.error("Failed to create purchase payment entry 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 purchase payment entry creation", transaction.getStatus()); - } - - transactionEventService.save(transactionEvent); - - return transactionRepository.save(transaction); - } -} 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 index 8c74349..88cf946 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/handlers/PollStatusHandler.java @@ -11,7 +11,6 @@ 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")