improving workflow fault tolerance

This commit is contained in:
2025-10-26 23:14:24 +02:00
parent 22ecc4b589
commit b5e891afed
18 changed files with 63 additions and 178 deletions

View File

@@ -169,7 +169,6 @@
<artifactId>spring-boot-starter-mail</artifactId> <artifactId>spring-boot-starter-mail</artifactId>
<version>3.1.5</version> <version>3.1.5</version>
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,79 +0,0 @@
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.
* <p>
* 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.
* </p>
*
* @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<Module> customModules) {
builder.modules(customModules);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return builder.build();
}
/**
* Overrides the default ObjectMapper used by the nFlow REST API.
* <p>
* This bean definition ensures that the nFlow REST endpoints use the same consistent,
* primary ObjectMapper as the rest of the application.
* </p>
*/
@Bean("nflowRestObjectMapper")
public ObjectMapper nflowRestObjectMapper(ObjectMapper primaryObjectMapper) {
return primaryObjectMapper;
}
/**
* Overrides the default ObjectMapper used by the nFlow Engine for variable serialization.
* <p>
* This is the key fix for `InvalidDefinitionException` with `LocalDateTime` in workflow variables.
* It ensures the engine uses our fully configured primary ObjectMapper.
* </p>
*/
@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;
}
}

View File

@@ -107,6 +107,7 @@ public class ConfigController {
.externalId("78f1f497-c9eb-401c-b22c-884756e68e40") .externalId("78f1f497-c9eb-401c-b22c-884756e68e40")
.integrationProcessorLabel("STEWARD") .integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number") .accountFieldName("Phone Number")
.percentageCommission(6)
.build(), .build(),
Provider.builder() Provider.builder()
.description("NetOne Airtime") .description("NetOne Airtime")
@@ -117,6 +118,7 @@ public class ConfigController {
.externalId("95d485a7-39c9-4559-8a27-6521969abe8f") .externalId("95d485a7-39c9-4559-8a27-6521969abe8f")
.integrationProcessorLabel("STEWARD") .integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number") .accountFieldName("Phone Number")
.percentageCommission(5.5)
.build(), .build(),
Provider.builder() Provider.builder()
.description("Prepaid Zesa") .description("Prepaid Zesa")
@@ -127,6 +129,7 @@ public class ConfigController {
.externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6") .externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6")
.integrationProcessorLabel("STEWARD") .integrationProcessorLabel("STEWARD")
.accountFieldName("Meter Number") .accountFieldName("Meter Number")
.percentageCommission(1.5)
.build(), .build(),
Provider.builder() Provider.builder()
.description("Econet Broadband") .description("Econet Broadband")
@@ -137,6 +140,7 @@ public class ConfigController {
.externalId("287863e2-a791-4106-b629-79dadc9a6779") .externalId("287863e2-a791-4106-b629-79dadc9a6779")
.integrationProcessorLabel("STEWARD") .integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number") .accountFieldName("Phone Number")
.percentageCommission(6)
.build() .build()
); );
providerService.getProviderRepository().saveAll(providers); providerService.getProviderRepository().saveAll(providers);

View File

@@ -65,6 +65,7 @@ public class OnboardingController {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(RegistrationWorkflow.TYPE) .setType(RegistrationWorkflow.TYPE)
.setExternalId(request.getUsername() + "-" + new Instant().getMillis()) .setExternalId(request.getUsername() + "-" + new Instant().getMillis())
.setBusinessKey(request.getUsername())
.putStateVariable("body", request) .putStateVariable("body", request)
.setNextActivation(DateTime.now()) .setNextActivation(DateTime.now())
.build(); .build();

View File

@@ -1,18 +1,31 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.EmailService; import zw.qantra.tm.domain.services.EmailService;
import zw.qantra.tm.domain.services.TransactionService;
@RestController @RestController
@RequestMapping("/test") @RequestMapping("/test")
@CrossOrigin(origins = "*") @CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class TestController { public class TestController {
@Autowired private final EmailService emailService;
private EmailService emailService; private final TransactionService transactionService;
@PostMapping("/dbtest")
public ResponseEntity<Object> dbtest(@RequestBody Transaction transaction) {
transactionService.save(transaction);
transaction.setType(RequestType.REQUEST);
transactionService.save(transaction);
return ResponseEntity.ok(transaction);
}
@GetMapping("/hello/{email}") @GetMapping("/hello/{email}")
public ResponseEntity<String> helloEndpoint(@PathVariable String email) { public ResponseEntity<String> helloEndpoint(@PathVariable String email) {

View File

@@ -1,6 +1,9 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import io.nflow.engine.internal.workflow.ObjectStringMapper; import io.nflow.engine.internal.workflow.ObjectStringMapper;
import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstance;
@@ -176,12 +179,21 @@ public class TransactonController {
@PostMapping @PostMapping
public ResponseEntity<Object> confirm(@RequestBody Transaction transaction) throws ExecutionException, InterruptedException { public ResponseEntity<Object> confirm(@RequestBody Transaction transaction)
throws ExecutionException, InterruptedException, NumberParseException {
LogUtils.logPrettyJson(logger, "incoming transaction", transaction); LogUtils.logPrettyJson(logger, "incoming transaction", transaction);
// phone must begin with '+'
// need to format the debit phone number since it's the business key
PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
Phonenumber.PhoneNumber number = phoneUtil.parse(transaction.getDebitPhone(), "");
String formattedPhoneNumber = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164);
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(TransactionWorkflow.TYPE) .setType(TransactionWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis()) .setExternalId(transaction.getUserId() + "-" + new Instant().getMillis())
.putStateVariable("confirmation", transaction) .setBusinessKey(formattedPhoneNumber)
.putStateVariable("confirmation", Utils.toJson(transaction))
.setNextActivation(DateTime.now()) .setNextActivation(DateTime.now())
.build(); .build();

View File

@@ -40,5 +40,6 @@ public class BaseEntity {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS")
private LocalDateTime updatedAt; // Or Date, Timestamp private LocalDateTime updatedAt; // Or Date, Timestamp
private Boolean deleted = false; @Column(columnDefinition = "BOOLEAN DEFAULT FALSE")
private Boolean deleted;
} }

View File

@@ -4,12 +4,14 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import org.springframework.data.domain.Persistable;
import zw.qantra.tm.domain.enums.AuthType; import zw.qantra.tm.domain.enums.AuthType;
import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.UUID;
@Entity @Entity
@Getter @Getter
@@ -18,7 +20,6 @@ import java.math.BigDecimal;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@SQLRestriction("deleted = false")
public class Transaction extends BaseEntity { public class Transaction extends BaseEntity {
private String userId; private String userId;
private String trace; private String trace;
@@ -101,5 +102,4 @@ public class Transaction extends BaseEntity {
@Transient @Transient
private Object additionalData; private Object additionalData;
} }

View File

@@ -13,4 +13,5 @@ import java.util.UUID;
@Repository @Repository
public interface TransactionRepository extends JpaRepository<Transaction, UUID>, JpaSpecificationExecutor<Transaction> { public interface TransactionRepository extends JpaRepository<Transaction, UUID>, JpaSpecificationExecutor<Transaction> {
Transaction findByTraceAndType(String trace, RequestType type); Transaction findByTraceAndType(String trace, RequestType type);
Optional<Transaction> findByReference(String reference);
} }

View File

@@ -18,6 +18,7 @@ import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.UUID;
@Service("CONFIRM_ECONET") @Service("CONFIRM_ECONET")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -80,7 +81,7 @@ public class EconetConfirmationProcessor implements TransactionProcessorInterfac
transaction.setErrorMessage((String) body.get("errorMessage")); transaction.setErrorMessage((String) body.get("errorMessage"));
} }
return transactionService.save(transaction); return transaction;
} }
@Override @Override

View File

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

View File

@@ -1,11 +0,0 @@
package zw.qantra.tm.domain.services.processors.handlers;
import zw.qantra.tm.domain.models.Transaction;
public class ValidationHandler implements LogicHandler<Transaction, Transaction> {
@Override
public Transaction process(Transaction transaction) {
System.out.println("processing validation");
return transaction;
}
}

View File

@@ -74,9 +74,12 @@ public class TransactionWorkflow extends WorkflowDefinition {
public NextAction calculateCharges(StateExecution execution) { public NextAction calculateCharges(StateExecution execution) {
try { try {
Transaction transaction = execution.getVariable("confirmation", Transaction.class); LinkedHashMap map = execution.getVariable("confirmation", LinkedHashMap.class);
byte[] json = objectMapper.writeValueAsBytes(map);
Transaction transaction = objectMapper.readValue(json, Transaction.class);
process(transaction, "calculateCharges"); process(transaction, "calculateCharges");
execution.setVariable("body", transaction); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(CLEANUP, "Charge calculation complete"); return NextAction.moveToState(CLEANUP, "Charge calculation complete");
} catch (Exception e) { } catch (Exception e) {
@@ -87,9 +90,12 @@ public class TransactionWorkflow extends WorkflowDefinition {
public NextAction cleanup(StateExecution execution) { public NextAction cleanup(StateExecution execution) {
try { try {
Transaction transaction = execution.getVariable("body", Transaction.class); LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class);
byte[] json = objectMapper.writeValueAsBytes(map);
Transaction transaction = objectMapper.readValue(json, Transaction.class);
process(transaction, "cleanup"); process(transaction, "cleanup");
execution.setVariable("body", transaction); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(CONFIRM, "Cleanup complete"); return NextAction.moveToState(CONFIRM, "Cleanup complete");
} catch (Exception e) { } catch (Exception e) {
@@ -100,7 +106,9 @@ public class TransactionWorkflow extends WorkflowDefinition {
public NextAction confirm(StateExecution execution) { public NextAction confirm(StateExecution execution) {
try { try {
Transaction transaction = execution.getVariable("body", Transaction.class); LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class);
byte[] json = objectMapper.writeValueAsBytes(map);
Transaction transaction = objectMapper.readValue(json, Transaction.class);
transaction.setWorkflowId(execution.getWorkflowInstanceId()); transaction.setWorkflowId(execution.getWorkflowInstanceId());
transaction = process(transaction, "confirm"); transaction = process(transaction, "confirm");
@@ -117,9 +125,7 @@ public class TransactionWorkflow extends WorkflowDefinition {
byte[] json = objectMapper.writeValueAsBytes(map); byte[] json = objectMapper.writeValueAsBytes(map);
Transaction transaction = objectMapper.readValue(json, Transaction.class); Transaction transaction = objectMapper.readValue(json, Transaction.class);
transaction.setWorkflowId(execution.getWorkflowInstanceId()); process(transaction, "recipientsUpdate");
transaction = process(transaction, "recipientsUpdate");
execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(CONFIRM_SUCCESS, "Transaction confirmed successfully"); return NextAction.moveToState(CONFIRM_SUCCESS, "Transaction confirmed successfully");
} }

View File

@@ -17,6 +17,7 @@ import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.UUID;
@Service("confirm") @Service("confirm")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -28,8 +29,8 @@ public class ConfirmHandler implements HandlerInterface {
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
transaction = transactionService.save(transaction);
transactionService.save(transaction);
TransactionEvent transactionEvent = TransactionEvent.builder() TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString()) .transactionId(transaction.getId().toString())
.event(transaction.getType().name()) .event(transaction.getType().name())
@@ -43,7 +44,7 @@ public class ConfirmHandler implements HandlerInterface {
try { try {
// MAIN TRANSACTION LOGIC! // MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction); transaction = (Transaction) transactionProcessorInterface.process(transaction);
transaction.setConfirmationStatus(transaction.getStatus()); transaction.setConfirmationStatus(transaction.getStatus());
}catch (Exception e){ }catch (Exception e){
@@ -60,15 +61,8 @@ public class ConfirmHandler implements HandlerInterface {
transactionEvent.setMessage(transaction.getErrorMessage()); transactionEvent.setMessage(transaction.getErrorMessage());
transactionEventService.save(transactionEvent); transactionEventService.save(transactionEvent);
transaction = transactionService.save(transaction); Transaction t1 = Utils.deepCopy(transaction, Transaction.class);
// fetch additionalData transactionService.save(transaction);
AdditionalData data = additionalDataService.getAdditionalDataRepository() return t1;
.findByTransactionIdAndRequestType(
transaction.getId().toString(), RequestType.CONFIRM).orElse(null);
if(data != null) {
transaction.setAdditionalData(Utils.fromJson(data.getJsonString(), List.class));
}
return transaction;
} }
} }

View File

@@ -13,7 +13,6 @@ import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; 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.LogicPipeline;
import zw.qantra.tm.domain.services.processors.handlers.ValidationHandler;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
@Service("integration") @Service("integration")

View File

@@ -1,5 +1,4 @@
spring.application.name=tm spring.application.name=tm
spring.main.allow-bean-definition-overriding=true
debug=false debug=false
logging.level.org.springframework.boot.autoconfigure=INFO logging.level.org.springframework.boot.autoconfigure=INFO